/** * 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'; /* ------------------------------- 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 `
${CATEGORIES.map((c) => { const n = counts[c.slug]; const emptyCls = n === 0 ? ' empty' : ''; const countLabel = `${n} ${n === 1 ? 'listing' : 'listings'}`; return `
${c.icon}

${escapeHtml(c.title)}

${verbose ? `

${escapeHtml(c.blurb)}

` : ''} ${countLabel}
`; }).join('\n ')}
`; } /* ------------------------------- 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: `
${cat.icon}

${escapeHtml(title)}

${escapeHtml(cat.blurb)}

${rows.length === 0 ? `No listings in this category yet. Be the first.` : `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'}, newest first.`}

${rows.length === 0 ? emptyCategory(cat) : renderListingList(rows)}

โ† All categories

`, }; } /* ------------------------------- 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 ? `${cat.icon} ${escapeHtml(cat.title)}` : ''; 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 = ``; const iconHtml = row.icon ? `` : `
${cat?.icon || '๐Ÿ“ฆ'}
`; 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: `
${iconHtml}

${escapeHtml(row.name)}

by ${escapeHtml(row.publisher)} ยท v${escapeHtml(row.version)} ยท ${catChip}

Download

${formatBytes(row.download_size)} ยท ${shortSha(row.download_sha256)} ${scanBadge}

Download
Full hash + source URL
SHA-256 ${row.download_sha256}

About

${escapeHtml(row.description)}

${row.homepage ? `

Project homepage โ†’

` : ''}

Who published this

Signed by ${escapeHtml(row.publisher)} โ€” a BNS name owned by ${escapeHtml(row.signer_address)}. 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.

Endorsements

No endorsements yet. 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.

${cat ? `โ† Back to ${escapeHtml(cat.title)}` : `โ† Browse`}

`, }; } /* ------------------------------- helpers ------------------------------- */ function renderListingList(rows) { return `
${rows.map(renderListingCard).join('\n')}
`; } function renderListingCard(row) { const href = `/app/${encodeURIComponent(row.publisher)}/${encodeURIComponent(row.slug)}`; return `

${escapeHtml(row.name)}

${escapeHtml(truncate(row.description, 160))}

by ${escapeHtml(row.publisher)} ยท v${escapeHtml(row.version)} ยท ${formatBytes(row.download_size)}
`; } function emptyCategory(cat) { return `
Nothing here yet. If you ship software that fits "${escapeHtml(cat.title)}", the submit flow is live โ€” upload, scan, sign, your listing appears in this view.
`; } function renderScanBadge(verdict, payload) { const engine = payload?.scan?.engine || ''; const sigs = payload?.scan?.signatures || []; switch (verdict) { case 'clean': return `scan โœ“ clean${engine ? ` (${escapeHtml(engine)})` : ''}`; case 'infected': return `scan โš  infected`; default: return `scan ? ${escapeHtml(verdict)}`; } } 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 ''; } }