Three top-level groups replace the flat eight categories on the home
page and /browse:
Apps 7 sub-categories inside: browsers / communication /
privacy / devtools / media / productivity / other
Games single bucket for now; sub-chips later when there's volume
FinTech single bucket for now (BCH wallets, bridges, POS, DeFi)
Routes:
/t/<slug> top-level page. For Apps shows the 7 sub-chips + an
aggregated listings view across them. For Games and
FinTech shows the listings directly (no sub-chips).
/c/<slug> sub-category page under Apps (browsers, communication, …).
The sole-sub categories /c/games and /c/fintech 301 to
/t/games and /t/fintech respectively, so each view has
exactly one canonical URL.
Slug renames + removals in the CATEGORIES allow-list on listings.js:
utilities → productivity (the display label was already pointing at
that in people's heads — "productivity"
fits storage + sync + file tools better)
wallets → fintech (broader scope: bridges + POS + DeFi,
not just wallets)
games stays as a top-level sole-bucket
browsers NEW sub-category under Apps for BCNR
resolvers, mobile browsers, extensions
Seed listings re-categorised + two slug fixes:
theseus.x/theseus-navigator-win category: other → browsers
ariadne.x/ariadne-resolver-win → RENAMED to ariadne-thread-win,
name "Ariadne's Thread
(Windows resolver)" shortened
to just "Ariadne's Thread";
category: privacy → browsers
ariadne.x/ariadne-android → RENAMED to ariadne-navigator,
name "Ariadne's Thread
(Android)" → "Ariadne
Navigator" (it is a
different program, not a
platform build of Thread);
category: privacy → browsers
pithos.x/pithos-desktop category: utilities → productivity
scripts/migrate-taxonomy.mjs is a one-shot that deletes every row with
signature='SM_OPERATOR_SEED'; a subsequent `seed.mjs` run then inserts
the fresh set with the new slugs. Publisher-POSTed listings (none in
production today) are left alone. Idempotent — a second run finds
nothing to remove.
New icons:
top-apps.svg 2×2 rounded tile grid (OS launcher metaphor)
cat-browsers.svg browser window + 3 chrome dots + content block
Three icons reused: cat-games.svg doubles as the Games top icon,
cat-wallets.svg as the FinTech top icon (same visual, different label);
cat-utilities.svg stays as the Productivity sub icon.
478 lines
19 KiB
JavaScript
478 lines
19 KiB
JavaScript
/**
|
|
* Browse pages — category index and per-listing detail.
|
|
*
|
|
* Everything here is rendered server-side from the SQLite listings table. No
|
|
* client JS, so Googlebot sees the real page, Theseus sees the real page, and
|
|
* the s3 snapshot (future milestone #12) is just the same HTML written to disk.
|
|
*
|
|
* These pages are also where the "0 listings" messaging on the home page and
|
|
* /browse flips to real counts — `countsByCategory()` is exported here and
|
|
* imported by pages.js so the two render surfaces agree on what is live.
|
|
*/
|
|
|
|
import {
|
|
listingsByCategory,
|
|
getListing,
|
|
countByCategory as countByCategoryDb,
|
|
} from './db.js';
|
|
import { escapeHtml } from './layout.js';
|
|
import { notFound } from './pages.js';
|
|
import { softwareApplicationLd } from './seo.js';
|
|
|
|
/* ------------------------------- taxonomy ------------------------------- */
|
|
|
|
/**
|
|
* Two levels: three top categories (Apps / Games / FinTech) on the home + browse
|
|
* tiles, and for Apps, seven sub-categories a reader clicks through to narrow.
|
|
* Games and FinTech do not have sub-categories at launch — their top-level
|
|
* page IS the listings page. (Sub-chips can be added inside them later when
|
|
* there's volume: Wallets / Bridges / POS inside FinTech, genres inside Games.)
|
|
*
|
|
* `icon` paths point at /static/icons/ SVGs rendered via <img> at the sizes
|
|
* the templates need. All icons share one family with the sibling .x marks:
|
|
* 12-radius dark square + acid-green mark.
|
|
*
|
|
* `CATEGORIES` carries the sub-category metadata AND the single-bucket
|
|
* categories under Games and FinTech. Every row has a `top` field that places
|
|
* it under one of the three top-level groups.
|
|
*/
|
|
export const TOP_CATEGORIES = [
|
|
{ slug: 'apps', icon: '/static/icons/top-apps.svg', title: 'Apps',
|
|
blurb: `Everything that isn't a game or a fintech tool. Browsers, messengers, media, developer tools, productivity.` },
|
|
{ slug: 'games', icon: '/static/icons/cat-games.svg', title: 'Games',
|
|
blurb: `Standalone titles, on-chain leaderboards, in-app purchases that reach the publisher instead of a 30% toll.` },
|
|
{ slug: 'fintech', icon: '/static/icons/cat-wallets.svg', title: 'FinTech',
|
|
blurb: `BCH wallets, cross-chain bridges, point-of-sale terminals, DeFi primitives. The chain publishes the listing and the chain settles the payment.` },
|
|
];
|
|
|
|
const TOP_BY_SLUG = Object.fromEntries(TOP_CATEGORIES.map((t) => [t.slug, t]));
|
|
|
|
export const CATEGORIES = [
|
|
// ===== Apps sub-categories =====
|
|
{ slug: 'browsers', top: 'apps', icon: '/static/icons/cat-browsers.svg', title: 'Browsers & web tools',
|
|
blurb: `BCNR resolvers, mobile browsers, extensions that teach other browsers to speak .x and the other decentralized TLDs.` },
|
|
{ slug: 'communication', top: 'apps', icon: '/static/icons/cat-communication.svg', title: 'Communication',
|
|
blurb: `Messengers, voice, boards, group chats. Clients that route through peers rather than someone else's server.` },
|
|
{ slug: 'privacy', top: 'apps', icon: '/static/icons/cat-privacy.svg', title: 'Privacy & security',
|
|
blurb: `Tor and mix-net tooling, key and vault managers, disk crypto. The things a conventional store delists first when a government writes.` },
|
|
{ slug: 'devtools', top: 'apps', icon: '/static/icons/cat-devtools.svg', title: 'Developer tools',
|
|
blurb: `SDKs, libraries, indexers, faucets, protocol utilities. The pieces other apps on this list are built out of.` },
|
|
{ slug: 'media', top: 'apps', icon: '/static/icons/cat-media.svg', title: 'Media & publishing',
|
|
blurb: `Readers, players, one-file blogs, podcast hosts. Clients that find their audience without going through an aggregator.` },
|
|
{ slug: 'productivity', top: 'apps', icon: '/static/icons/cat-utilities.svg', title: 'Productivity',
|
|
blurb: `File tools, storage, format converters, backup, calculators. The small sharp things that solve one problem and do not ask for an account first.` },
|
|
{ slug: 'other', top: 'apps', icon: '/static/icons/cat-other.svg', title: 'Other',
|
|
blurb: `Apps that don't fit the categories above. A new category can be proposed by publishing against it and asking an operator to promote it.` },
|
|
|
|
// ===== Games (single bucket for now) =====
|
|
{ slug: 'games', top: 'games', icon: '/static/icons/cat-games.svg', title: 'Games',
|
|
blurb: TOP_BY_SLUG.games?.blurb || '' },
|
|
|
|
// ===== FinTech (single bucket for now) =====
|
|
{ slug: 'fintech', top: 'fintech', icon: '/static/icons/cat-wallets.svg', title: 'FinTech',
|
|
blurb: TOP_BY_SLUG.fintech?.blurb || '' },
|
|
];
|
|
|
|
const CATEGORY_BY_SLUG = Object.fromEntries(CATEGORIES.map((c) => [c.slug, c]));
|
|
const CATEGORIES_BY_TOP = Object.fromEntries(
|
|
TOP_CATEGORIES.map((t) => [t.slug, CATEGORIES.filter((c) => c.top === t.slug)])
|
|
);
|
|
|
|
/**
|
|
* {slug: count} for every sub-category — categories with 0 listings are present
|
|
* with the value 0, so templates can read them unconditionally.
|
|
*/
|
|
export function countsByCategory() {
|
|
const raw = countByCategoryDb();
|
|
const out = {};
|
|
for (const c of CATEGORIES) out[c.slug] = raw[c.slug] || 0;
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* {slug: count} for every top-level group — sums its subs' counts.
|
|
*/
|
|
export function countsByTop() {
|
|
const subs = countsByCategory();
|
|
const out = {};
|
|
for (const t of TOP_CATEGORIES) {
|
|
out[t.slug] = CATEGORIES_BY_TOP[t.slug].reduce((n, c) => n + subs[c.slug], 0);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The three big tiles — the primary navigation into the directory.
|
|
* Used on the home page and on /browse.
|
|
*/
|
|
export function renderTopGrid({ verbose = true } = {}) {
|
|
const counts = countsByTop();
|
|
return `<div class="categories">
|
|
${TOP_CATEGORIES.map((t) => {
|
|
const n = counts[t.slug];
|
|
const emptyCls = n === 0 ? ' empty' : '';
|
|
const countLabel = `${n} ${n === 1 ? 'listing' : 'listings'}`;
|
|
return `<a class="cat${emptyCls}" href="/t/${t.slug}">
|
|
<img class="ico" src="${t.icon}" alt="" width="40" height="40">
|
|
<h3>${escapeHtml(t.title)}</h3>
|
|
${verbose ? `<p>${escapeHtml(t.blurb)}</p>` : ''}
|
|
<span class="count">${countLabel}</span>
|
|
</a>`;
|
|
}).join('\n ')}
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* Secondary grid of the sub-categories under one top. Only used on /t/apps
|
|
* (Games and FinTech are single-bucket so their top page is the listings).
|
|
*/
|
|
export function renderSubGrid(topSlug) {
|
|
const subs = CATEGORIES_BY_TOP[topSlug] || [];
|
|
if (subs.length < 2) return ''; // nothing worth showing for a single-sub top
|
|
const counts = countsByCategory();
|
|
return `<div class="categories">
|
|
${subs.map((c) => {
|
|
const n = counts[c.slug];
|
|
const emptyCls = n === 0 ? ' empty' : '';
|
|
const countLabel = `${n} ${n === 1 ? 'listing' : 'listings'}`;
|
|
return `<a class="cat${emptyCls}" href="/c/${c.slug}">
|
|
<img class="ico" src="${c.icon}" alt="" width="40" height="40">
|
|
<h3>${escapeHtml(c.title)}</h3>
|
|
<p>${escapeHtml(c.blurb)}</p>
|
|
<span class="count">${countLabel}</span>
|
|
</a>`;
|
|
}).join('\n ')}
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* Kept as an alias so pages.js's older call site still works; home page now
|
|
* shows the top grid instead of the sub grid. Removing this would be a
|
|
* minor churn in pages.js that landed in the previous commit.
|
|
*/
|
|
export const renderCategoriesGrid = renderTopGrid;
|
|
|
|
/* ------------------------------- top-category page ------------------------------- */
|
|
|
|
export function topCategoryPage(req, slug) {
|
|
const top = TOP_BY_SLUG[slug];
|
|
if (!top) return notFound(req);
|
|
|
|
const subs = CATEGORIES_BY_TOP[slug] || [];
|
|
const subSlugs = subs.map((c) => c.slug);
|
|
|
|
// Aggregate across all subs. For single-bucket tops (Games, FinTech)
|
|
// this is just one slug; for Apps it's seven.
|
|
const rows = [];
|
|
for (const s of subSlugs) rows.push(...listingsByCategory(s));
|
|
rows.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
|
|
|
|
const title = top.title;
|
|
const here = `/t/${slug}`;
|
|
const hasSubs = subs.length > 1;
|
|
|
|
return {
|
|
title,
|
|
description: `${top.title} on Prometheus — ${top.blurb}`,
|
|
here,
|
|
body: `
|
|
<header class="hero compact">
|
|
<img class="mark" src="${top.icon}" alt="" width="64" height="64" style="display:block;margin:0 auto 1rem">
|
|
<h1>${escapeHtml(title)}</h1>
|
|
<p class="tag">${escapeHtml(top.blurb)}</p>
|
|
</header>
|
|
|
|
<div class="wrap">
|
|
|
|
${hasSubs ? `
|
|
<section class="plain">
|
|
<h2>Narrow by sub-category</h2>
|
|
${renderSubGrid(slug)}
|
|
</section>
|
|
|
|
<section>
|
|
<h2>All ${escapeHtml(title.toLowerCase())}</h2>
|
|
<p class="lede">
|
|
${rows.length === 0
|
|
? `No listings in ${escapeHtml(title)} yet. <a href="/submit">Be the first</a>.`
|
|
: `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'} across ${subs.filter((c) => countsByCategory()[c.slug] > 0).length} sub-categories, newest first.`}
|
|
</p>
|
|
${rows.length === 0 ? '' : renderListingList(rows)}
|
|
</section>
|
|
` : `
|
|
<section class="plain">
|
|
<p class="lede">
|
|
${rows.length === 0
|
|
? `No listings in ${escapeHtml(title)} yet. <a href="/submit">Be the first</a>.`
|
|
: `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'}, newest first.`}
|
|
</p>
|
|
${rows.length === 0 ? emptyTop(top) : renderListingList(rows)}
|
|
</section>
|
|
`}
|
|
|
|
<p style="margin-top:1.8rem;color:var(--dim);font-size:13px">
|
|
<a href="/browse">← All categories</a>
|
|
</p>
|
|
|
|
</div>`,
|
|
};
|
|
}
|
|
|
|
function emptyTop(top) {
|
|
return `<div class="note">
|
|
<b>Nothing here yet.</b> If you ship ${escapeHtml(top.title.toLowerCase())},
|
|
the <a href="/submit">submit flow</a> is live — upload, scan, sign, your listing
|
|
appears in this view.
|
|
</div>`;
|
|
}
|
|
|
|
/* ------------------------------- category page ------------------------------- */
|
|
|
|
export function categoryPage(req, slug) {
|
|
const cat = CATEGORY_BY_SLUG[slug];
|
|
if (!cat) return notFound(req);
|
|
|
|
// Sole-sub categories (games, fintech) redirect to their /t/<top> page,
|
|
// because the top page IS the listings view and we don't want two URLs
|
|
// for the same content.
|
|
const top = TOP_BY_SLUG[cat.top];
|
|
const soleSub = top && CATEGORIES_BY_TOP[cat.top].length === 1;
|
|
if (soleSub) {
|
|
return {
|
|
title: cat.title,
|
|
description: cat.blurb,
|
|
status: 301,
|
|
redirect: `/t/${cat.top}`,
|
|
here: '',
|
|
body: '',
|
|
};
|
|
}
|
|
|
|
const rows = listingsByCategory(slug);
|
|
const title = cat.title;
|
|
const here = `/c/${slug}`;
|
|
const backLabel = top ? `← Back to ${escapeHtml(top.title)}` : '← All categories';
|
|
const backHref = top ? `/t/${top.slug}` : '/browse';
|
|
|
|
return {
|
|
title,
|
|
description: `${cat.title} on Prometheus — ${cat.blurb}`,
|
|
here,
|
|
body: `
|
|
<header class="hero compact">
|
|
<img class="mark" src="${cat.icon}" alt="" width="56" height="56" style="display:block;margin:0 auto 1rem">
|
|
<h1>${escapeHtml(title)}</h1>
|
|
<p class="tag">${escapeHtml(cat.blurb)}</p>
|
|
</header>
|
|
|
|
<div class="wrap">
|
|
|
|
<section class="plain">
|
|
<p class="lede">
|
|
${rows.length === 0
|
|
? `No listings in this category yet. <a href="/submit">Be the first</a>.`
|
|
: `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'}, newest first.`}
|
|
</p>
|
|
|
|
${rows.length === 0 ? emptyCategory(cat) : renderListingList(rows)}
|
|
|
|
<p style="margin-top:1.8rem;color:var(--dim);font-size:13px">
|
|
<a href="${backHref}">${backLabel}</a>
|
|
</p>
|
|
</section>
|
|
|
|
</div>`,
|
|
};
|
|
}
|
|
|
|
/* ------------------------------- app detail page ------------------------------- */
|
|
|
|
export function appPage(req, publisher, slug) {
|
|
const id = `${String(publisher).toLowerCase()}/${String(slug).toLowerCase()}`;
|
|
const row = getListing(id);
|
|
if (!row || row.withdrawn_at) return notFound(req);
|
|
|
|
const cat = CATEGORY_BY_SLUG[row.category];
|
|
// Sole-sub categories (games, fintech) show as the top-level chip since
|
|
// the sub URL just redirects to the top anyway.
|
|
const chipTarget = cat && TOP_BY_SLUG[cat.top] && CATEGORIES_BY_TOP[cat.top].length === 1
|
|
? { href: `/t/${cat.top}`, title: TOP_BY_SLUG[cat.top].title, icon: TOP_BY_SLUG[cat.top].icon }
|
|
: cat
|
|
? { href: `/c/${cat.slug}`, title: cat.title, icon: cat.icon }
|
|
: null;
|
|
const catChip = chipTarget
|
|
? `<a href="${chipTarget.href}" class="pill p-upcoming" style="text-decoration:none;display:inline-flex;align-items:center;gap:4px"><img src="${chipTarget.icon}" alt="" width="14" height="14" style="vertical-align:-2px"> ${escapeHtml(chipTarget.title)}</a>`
|
|
: '';
|
|
|
|
const scanReport = safeParse(row.scan_report);
|
|
const scanPayload = scanReport?.payload ? safeParse(fromBase64Url(scanReport.payload)) : null;
|
|
const verdict = scanPayload?.scan?.verdict || 'unknown';
|
|
const scanBadge = renderScanBadge(verdict, scanPayload);
|
|
|
|
const ld = softwareApplicationLd({ listing: row, scanVerdict: verdict });
|
|
const headExtra = `<script type="application/ld+json">${ld}</script>`;
|
|
|
|
const iconHtml = row.icon
|
|
? `<img src="${escapeAttr(row.icon)}" alt="" width="80" height="80"
|
|
style="width:80px;height:80px;border-radius:16px;border:1px solid var(--line);background:var(--panel);object-fit:cover">`
|
|
: cat?.icon
|
|
? `<img src="${cat.icon}" alt="" width="80" height="80"
|
|
style="width:80px;height:80px;border-radius:16px;border:1px solid var(--line);background:var(--panel);padding:12px">`
|
|
: `<div style="width:80px;height:80px;border-radius:16px;border:1px solid var(--line);background:var(--panel);display:grid;place-items:center;font-size:36px">📦</div>`;
|
|
|
|
return {
|
|
title: `${row.name} by ${row.publisher}`,
|
|
description: row.description.slice(0, 200),
|
|
here: `/app/${row.publisher}/${row.slug}`,
|
|
ogType: 'article',
|
|
ogImage: row.icon || 'https://prometheus.x/static/favicon.svg',
|
|
headExtra,
|
|
body: `
|
|
<header class="hero compact" style="padding-bottom:1rem">
|
|
<div style="display:flex;gap:18px;align-items:center;justify-content:center;flex-wrap:wrap;margin-bottom:.8rem">
|
|
${iconHtml}
|
|
<div style="text-align:left">
|
|
<h1 style="margin:0;font-size:1.8rem">${escapeHtml(row.name)}</h1>
|
|
<div style="color:var(--mut);font-size:14px;margin-top:.3rem">
|
|
by <b style="color:var(--ink)">${escapeHtml(row.publisher)}</b> ·
|
|
v${escapeHtml(row.version)} ·
|
|
${catChip}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="wrap">
|
|
|
|
<section class="plain">
|
|
<div class="card" style="padding:22px 26px">
|
|
<div style="display:flex;gap:1rem;align-items:center;justify-content:space-between;flex-wrap:wrap">
|
|
<div>
|
|
<h3 style="margin:0 0 .3rem;font-size:1.05rem">Download</h3>
|
|
<p style="margin:0;color:var(--mut);font-size:13px">
|
|
${formatBytes(row.download_size)} ·
|
|
<code class="inline" title="SHA-256">${shortSha(row.download_sha256)}</code>
|
|
${scanBadge}
|
|
</p>
|
|
</div>
|
|
<a class="btn acid" href="${escapeAttr(row.download_url)}" rel="noopener">Download</a>
|
|
</div>
|
|
<details style="margin-top:1rem">
|
|
<summary style="color:var(--dim);font-size:13px;cursor:pointer">Full hash + source URL</summary>
|
|
<div style="margin-top:.6rem;font-size:13px;color:var(--mut)">
|
|
<div><b style="color:var(--ink)">URL</b> <a href="${escapeAttr(row.download_url)}" rel="noopener">${escapeHtml(row.download_url)}</a></div>
|
|
<div style="margin-top:.4rem"><b style="color:var(--ink)">SHA-256</b> <code class="inline">${row.download_sha256}</code></div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>About</h2>
|
|
<p style="white-space:pre-wrap">${escapeHtml(row.description)}</p>
|
|
${row.homepage ? `<p style="margin-top:1rem"><a href="${escapeAttr(row.homepage)}" rel="noopener">Project homepage →</a></p>` : ''}
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Who published this</h2>
|
|
<p>Signed by <b>${escapeHtml(row.publisher)}</b> — a BNS name owned by
|
|
<code class="inline">${escapeHtml(row.signer_address)}</code>. Any installer
|
|
the chain says belongs to that key is trusted by your client; any client
|
|
that follows a different trust list will not see it as trusted.</p>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Endorsements</h2>
|
|
<div class="note"><b>No endorsements yet.</b> An endorsement is a signed
|
|
on-chain statement from any BNS name saying "I vouch for this app." Readers
|
|
whose trust list includes the endorser see the endorsement; those who do
|
|
not, do not. The endorsement protocol lands with APS1.</div>
|
|
</section>
|
|
|
|
<p style="margin-top:1.8rem;color:var(--dim);font-size:13px">
|
|
${chipTarget ? `<a href="${chipTarget.href}">← Back to ${escapeHtml(chipTarget.title)}</a>` : `<a href="/browse">← Browse</a>`}
|
|
</p>
|
|
|
|
</div>`,
|
|
};
|
|
}
|
|
|
|
/* ------------------------------- helpers ------------------------------- */
|
|
|
|
function renderListingList(rows) {
|
|
return `<div class="grid" style="margin-top:1.2rem">
|
|
${rows.map(renderListingCard).join('\n')}
|
|
</div>`;
|
|
}
|
|
|
|
function renderListingCard(row) {
|
|
const href = `/app/${encodeURIComponent(row.publisher)}/${encodeURIComponent(row.slug)}`;
|
|
return `<a class="card" href="${href}"
|
|
style="text-decoration:none;color:inherit;display:flex;flex-direction:column;gap:.5rem">
|
|
<h3 style="margin:0">${escapeHtml(row.name)}</h3>
|
|
<p style="margin:0;color:var(--mut);font-size:13.5px;line-height:1.5">
|
|
${escapeHtml(truncate(row.description, 160))}
|
|
</p>
|
|
<div style="color:var(--dim);font-size:12px;margin-top:auto;padding-top:.5rem">
|
|
by ${escapeHtml(row.publisher)} · v${escapeHtml(row.version)} · ${formatBytes(row.download_size)}
|
|
</div>
|
|
</a>`;
|
|
}
|
|
|
|
function emptyCategory(cat) {
|
|
return `<div class="note">
|
|
<b>Nothing here yet.</b> If you ship software that fits "${escapeHtml(cat.title)}",
|
|
the <a href="/submit">submit flow</a> is live — upload, scan, sign, your listing
|
|
appears in this view.
|
|
</div>`;
|
|
}
|
|
|
|
function renderScanBadge(verdict, payload) {
|
|
const engine = payload?.scan?.engine || '';
|
|
const sigs = payload?.scan?.signatures || [];
|
|
switch (verdict) {
|
|
case 'clean':
|
|
return `<span class="pill p-live" style="margin-left:.4rem">scan ✓ clean${engine ? ` (${escapeHtml(engine)})` : ''}</span>`;
|
|
case 'infected':
|
|
return `<span class="pill" style="background:rgba(246,118,138,.14);color:var(--taken);margin-left:.4rem" title="${escapeAttr(sigs.join(', '))}">scan ⚠ infected</span>`;
|
|
default:
|
|
return `<span class="pill p-upcoming" style="margin-left:.4rem">scan ? ${escapeHtml(verdict)}</span>`;
|
|
}
|
|
}
|
|
|
|
function formatBytes(n) {
|
|
const num = Number(n) || 0;
|
|
if (num < 1024) return `${num} B`;
|
|
if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KiB`;
|
|
if (num < 1024 * 1024 * 1024) return `${(num / 1024 / 1024).toFixed(1)} MiB`;
|
|
return `${(num / 1024 / 1024 / 1024).toFixed(2)} GiB`;
|
|
}
|
|
|
|
function shortSha(sha) {
|
|
if (!sha) return '';
|
|
const s = String(sha);
|
|
if (s.length <= 16) return s;
|
|
return `${s.slice(0, 8)}…${s.slice(-6)}`;
|
|
}
|
|
|
|
function truncate(s, max) {
|
|
const str = String(s || '');
|
|
if (str.length <= max) return str;
|
|
return str.slice(0, max - 1) + '…';
|
|
}
|
|
|
|
function escapeAttr(s) {
|
|
// Same as text escape; attributes are also always wrapped in double-quotes.
|
|
return escapeHtml(s);
|
|
}
|
|
|
|
function safeParse(s) {
|
|
if (!s) return null;
|
|
if (typeof s === 'object') return s;
|
|
try { return JSON.parse(s); } catch { return null; }
|
|
}
|
|
|
|
function fromBase64Url(s) {
|
|
try { return Buffer.from(String(s), 'base64url').toString('utf8'); }
|
|
catch { return ''; }
|
|
}
|