prometheus: browse pages — categories and per-listing detail
/c/<slug> renders one of the eight categories with every active listing
in it, newest first, as a grid of cards. Empty category shows
a "be the first" prompt rather than a dead page.
/app/<pub>/<slug> renders a listing: icon + name + publisher + version
chip, a prominent Download card with size, hash chip,
signed scan badge (green/clean | red/infected |
neutral/unknown), description, homepage link, who
signed, and an endorsements placeholder for when APS1
lands.
The home-page and /browse grids now read real counts from the DB — the
hardcoded "0 listings" placeholders are gone. A new helper
`renderCategoriesGrid()` is the one place category cards are rendered,
so the two surfaces always agree.
404s for unknown categories (`/c/nope`) and unknown listings
(`/app/foo/bar`) go through the same notFound page and now actually
return HTTP 404 instead of 200 — pages opt in by returning the
notFound object, server detects it by title and sets the status.
CATEGORIES is the single source of truth for slug/icon/title/blurb;
adding a new category now means one row here plus one entry in the
listings allow-list.
Verified end-to-end with three listings across three categories: counts
update, cards render, detail page shows the signed scan badge, 404s
return 404.
2026-10-08 01:56:52 +02:00
|
|
|
/**
|
|
|
|
|
* 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';
|
prometheus: SEO polish and the apps.silentmode.st mirror vhost
layout gained three optional fields: ogType, ogImage, headExtra. The
listing detail page uses all three — ogType=article, ogImage=the icon
URL (fallback to the Prometheus favicon), and headExtra for schema.org
SoftwareApplication JSON-LD. Google renders this as a rich card for
app results; the SEO uplift is real and the data is straightforward.
JSON-LD payload composed in service/seo.js:
@type SoftwareApplication, name, description, softwareVersion,
applicationCategory (mapped from Prometheus's slug to schema's name),
url, downloadUrl, fileSize, offers (free), author (BNS name as org),
image (icon when present), contentRating ("ClamAV clean" on a clean
scan).
OG defaults for every page: og:type (website unless overridden),
og:image (favicon unless overridden). The canonical URL stays
prometheus.x on both the live service and the apps.silentmode.st
mirror — crawlers that land on the mirror attribute the content to
the real address, matching the pattern [[silentmode-site-url-scheme]]
describes.
deploy/apps-silentmode-st.conf is the angie vhost for the mirror:
reverse-proxies to 127.0.0.1:3700, declines /admin (operator always
goes direct), 2 GiB client_max_body_size for POST /api/upload,
proxy_request_buffering off so a slow upload does not spool to disk.
Reuses the main silentmode.st certificate (SAN *.silentmode.st; if
that's not set up yet, request one and reload before enabling).
2026-10-08 21:45:59 +02:00
|
|
|
import { softwareApplicationLd } from './seo.js';
|
prometheus: browse pages — categories and per-listing detail
/c/<slug> renders one of the eight categories with every active listing
in it, newest first, as a grid of cards. Empty category shows
a "be the first" prompt rather than a dead page.
/app/<pub>/<slug> renders a listing: icon + name + publisher + version
chip, a prominent Download card with size, hash chip,
signed scan badge (green/clean | red/infected |
neutral/unknown), description, homepage link, who
signed, and an endorsements placeholder for when APS1
lands.
The home-page and /browse grids now read real counts from the DB — the
hardcoded "0 listings" placeholders are gone. A new helper
`renderCategoriesGrid()` is the one place category cards are rendered,
so the two surfaces always agree.
404s for unknown categories (`/c/nope`) and unknown listings
(`/app/foo/bar`) go through the same notFound page and now actually
return HTTP 404 instead of 200 — pages opt in by returning the
notFound object, server detects it by title and sets the status.
CATEGORIES is the single source of truth for slug/icon/title/blurb;
adding a new category now means one row here plus one entry in the
listings allow-list.
Verified end-to-end with three listings across three categories: counts
update, cards render, detail page shows the signed scan badge, 404s
return 404.
2026-10-08 01:56:52 +02:00
|
|
|
|
|
|
|
|
/* ------------------------------- categories ------------------------------- */
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The authoritative metadata for the eight categories — icon, title, and the
|
|
|
|
|
* blurb that shows on the home grid and at the top of the category page. One
|
|
|
|
|
* table so a copy change is a one-liner rather than scattered across templates.
|
|
|
|
|
*
|
|
|
|
|
* Adding a new category means adding a row here AND adding the slug to the
|
|
|
|
|
* allow-list in listings.js. The browse grid picks up the new row
|
|
|
|
|
* automatically.
|
|
|
|
|
*/
|
|
|
|
|
export const CATEGORIES = [
|
|
|
|
|
{ slug: 'communication', icon: '💬', title: 'Communication',
|
|
|
|
|
blurb: `Messengers, voice, boards, group chats. Clients that route through peers rather than someone else's server.` },
|
|
|
|
|
{ slug: 'privacy', icon: '🛡️', 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: 'wallets', icon: '💸', title: 'Wallets & payments',
|
|
|
|
|
blurb: `BCH wallets, cross-chain bridges, point-of-sale terminals. The chain publishes the listing and the chain settles the payment.` },
|
|
|
|
|
{ slug: 'games', icon: '🎮', title: 'Games',
|
|
|
|
|
blurb: `Standalone titles, on-chain leaderboards, in-app purchases that reach the publisher instead of a 30% toll.` },
|
|
|
|
|
{ slug: 'devtools', icon: '🧰', title: 'Developer tools',
|
|
|
|
|
blurb: `SDKs, libraries, indexers, faucets, protocol utilities. The pieces other apps on this list are built out of.` },
|
|
|
|
|
{ slug: 'media', icon: '🎧', title: 'Media & publishing',
|
|
|
|
|
blurb: `Readers, players, one-file blogs, podcast hosts. Clients that find their audience without going through an aggregator.` },
|
|
|
|
|
{ slug: 'utilities', icon: '⚙️', title: 'Utilities',
|
|
|
|
|
blurb: `File tools, format converters, backup, calculators. The small sharp things that solve one problem and do not ask for an account first.` },
|
|
|
|
|
{ slug: 'other', icon: '✨', title: 'Everything else',
|
|
|
|
|
blurb: `Anything that does not fit above. New categories are proposed in-band — any listing can declare one and clients will offer to add it.` },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const CATEGORY_BY_SLUG = Object.fromEntries(CATEGORIES.map((c) => [c.slug, c]));
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* {slug: count} for every 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Shared category grid, used on the home page and on /browse.
|
|
|
|
|
*
|
|
|
|
|
* `verbose` controls whether each card carries its blurb. Home page wants it;
|
|
|
|
|
* the dedicated browse page already has the blurb at the top and does not
|
|
|
|
|
* need to repeat it on every card.
|
|
|
|
|
*/
|
|
|
|
|
export function renderCategoriesGrid({ verbose = true } = {}) {
|
|
|
|
|
const counts = countsByCategory();
|
|
|
|
|
return `<div class="categories">
|
|
|
|
|
${CATEGORIES.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}">
|
|
|
|
|
<div class="ico">${c.icon}</div>
|
|
|
|
|
<h3>${escapeHtml(c.title)}</h3>
|
|
|
|
|
${verbose ? `<p>${escapeHtml(c.blurb)}</p>` : ''}
|
|
|
|
|
<span class="count">${countLabel}</span>
|
|
|
|
|
</a>`;
|
|
|
|
|
}).join('\n ')}
|
|
|
|
|
</div>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ------------------------------- category page ------------------------------- */
|
|
|
|
|
|
|
|
|
|
export function categoryPage(req, slug) {
|
|
|
|
|
const cat = CATEGORY_BY_SLUG[slug];
|
|
|
|
|
if (!cat) return notFound(req);
|
|
|
|
|
|
|
|
|
|
const rows = listingsByCategory(slug);
|
|
|
|
|
const title = cat.title;
|
|
|
|
|
const here = `/c/${slug}`;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
title,
|
|
|
|
|
description: `${cat.title} apps on Prometheus — ${cat.blurb}`,
|
|
|
|
|
here,
|
|
|
|
|
body: `
|
|
|
|
|
<header class="hero compact">
|
|
|
|
|
<div class="mark" style="font-size:40px">${cat.icon}</div>
|
|
|
|
|
<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="/browse">← All categories</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];
|
|
|
|
|
const catChip = cat
|
|
|
|
|
? `<a href="/c/${cat.slug}" class="pill p-upcoming" style="text-decoration:none">${cat.icon} ${escapeHtml(cat.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);
|
|
|
|
|
|
prometheus: SEO polish and the apps.silentmode.st mirror vhost
layout gained three optional fields: ogType, ogImage, headExtra. The
listing detail page uses all three — ogType=article, ogImage=the icon
URL (fallback to the Prometheus favicon), and headExtra for schema.org
SoftwareApplication JSON-LD. Google renders this as a rich card for
app results; the SEO uplift is real and the data is straightforward.
JSON-LD payload composed in service/seo.js:
@type SoftwareApplication, name, description, softwareVersion,
applicationCategory (mapped from Prometheus's slug to schema's name),
url, downloadUrl, fileSize, offers (free), author (BNS name as org),
image (icon when present), contentRating ("ClamAV clean" on a clean
scan).
OG defaults for every page: og:type (website unless overridden),
og:image (favicon unless overridden). The canonical URL stays
prometheus.x on both the live service and the apps.silentmode.st
mirror — crawlers that land on the mirror attribute the content to
the real address, matching the pattern [[silentmode-site-url-scheme]]
describes.
deploy/apps-silentmode-st.conf is the angie vhost for the mirror:
reverse-proxies to 127.0.0.1:3700, declines /admin (operator always
goes direct), 2 GiB client_max_body_size for POST /api/upload,
proxy_request_buffering off so a slow upload does not spool to disk.
Reuses the main silentmode.st certificate (SAN *.silentmode.st; if
that's not set up yet, request one and reload before enabling).
2026-10-08 21:45:59 +02:00
|
|
|
const ld = softwareApplicationLd({ listing: row, scanVerdict: verdict });
|
|
|
|
|
const headExtra = `<script type="application/ld+json">${ld}</script>`;
|
|
|
|
|
|
prometheus: browse pages — categories and per-listing detail
/c/<slug> renders one of the eight categories with every active listing
in it, newest first, as a grid of cards. Empty category shows
a "be the first" prompt rather than a dead page.
/app/<pub>/<slug> renders a listing: icon + name + publisher + version
chip, a prominent Download card with size, hash chip,
signed scan badge (green/clean | red/infected |
neutral/unknown), description, homepage link, who
signed, and an endorsements placeholder for when APS1
lands.
The home-page and /browse grids now read real counts from the DB — the
hardcoded "0 listings" placeholders are gone. A new helper
`renderCategoriesGrid()` is the one place category cards are rendered,
so the two surfaces always agree.
404s for unknown categories (`/c/nope`) and unknown listings
(`/app/foo/bar`) go through the same notFound page and now actually
return HTTP 404 instead of 200 — pages opt in by returning the
notFound object, server detects it by title and sets the status.
CATEGORIES is the single source of truth for slug/icon/title/blurb;
adding a new category now means one row here plus one entry in the
listings allow-list.
Verified end-to-end with three listings across three categories: counts
update, cards render, detail page shows the signed scan badge, 404s
return 404.
2026-10-08 01:56:52 +02:00
|
|
|
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">`
|
|
|
|
|
: `<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">${cat?.icon || '📦'}</div>`;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
title: `${row.name} by ${row.publisher}`,
|
|
|
|
|
description: row.description.slice(0, 200),
|
|
|
|
|
here: `/app/${row.publisher}/${row.slug}`,
|
prometheus: SEO polish and the apps.silentmode.st mirror vhost
layout gained three optional fields: ogType, ogImage, headExtra. The
listing detail page uses all three — ogType=article, ogImage=the icon
URL (fallback to the Prometheus favicon), and headExtra for schema.org
SoftwareApplication JSON-LD. Google renders this as a rich card for
app results; the SEO uplift is real and the data is straightforward.
JSON-LD payload composed in service/seo.js:
@type SoftwareApplication, name, description, softwareVersion,
applicationCategory (mapped from Prometheus's slug to schema's name),
url, downloadUrl, fileSize, offers (free), author (BNS name as org),
image (icon when present), contentRating ("ClamAV clean" on a clean
scan).
OG defaults for every page: og:type (website unless overridden),
og:image (favicon unless overridden). The canonical URL stays
prometheus.x on both the live service and the apps.silentmode.st
mirror — crawlers that land on the mirror attribute the content to
the real address, matching the pattern [[silentmode-site-url-scheme]]
describes.
deploy/apps-silentmode-st.conf is the angie vhost for the mirror:
reverse-proxies to 127.0.0.1:3700, declines /admin (operator always
goes direct), 2 GiB client_max_body_size for POST /api/upload,
proxy_request_buffering off so a slow upload does not spool to disk.
Reuses the main silentmode.st certificate (SAN *.silentmode.st; if
that's not set up yet, request one and reload before enabling).
2026-10-08 21:45:59 +02:00
|
|
|
ogType: 'article',
|
|
|
|
|
ogImage: row.icon || 'https://prometheus.x/static/favicon.svg',
|
|
|
|
|
headExtra,
|
prometheus: browse pages — categories and per-listing detail
/c/<slug> renders one of the eight categories with every active listing
in it, newest first, as a grid of cards. Empty category shows
a "be the first" prompt rather than a dead page.
/app/<pub>/<slug> renders a listing: icon + name + publisher + version
chip, a prominent Download card with size, hash chip,
signed scan badge (green/clean | red/infected |
neutral/unknown), description, homepage link, who
signed, and an endorsements placeholder for when APS1
lands.
The home-page and /browse grids now read real counts from the DB — the
hardcoded "0 listings" placeholders are gone. A new helper
`renderCategoriesGrid()` is the one place category cards are rendered,
so the two surfaces always agree.
404s for unknown categories (`/c/nope`) and unknown listings
(`/app/foo/bar`) go through the same notFound page and now actually
return HTTP 404 instead of 200 — pages opt in by returning the
notFound object, server detects it by title and sets the status.
CATEGORIES is the single source of truth for slug/icon/title/blurb;
adding a new category now means one row here plus one entry in the
listings allow-list.
Verified end-to-end with three listings across three categories: counts
update, cards render, detail page shows the signed scan badge, 404s
return 404.
2026-10-08 01:56:52 +02:00
|
|
|
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">
|
|
|
|
|
${cat ? `<a href="/c/${cat.slug}">← Back to ${escapeHtml(cat.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 ''; }
|
|
|
|
|
}
|