/** * 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 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: `Web3 browsers and decentralized domain name resolvers. BCNR tooling that teaches Chrome, Firefox and other browsers to reach .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 `
${TOP_CATEGORIES.map((t) => { const n = counts[t.slug]; const emptyCls = n === 0 ? ' empty' : ''; const countLabel = `${n} ${n === 1 ? 'listing' : 'listings'}`; return `

${escapeHtml(t.title)}

${verbose ? `

${escapeHtml(t.blurb)}

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

${escapeHtml(c.title)}

${escapeHtml(c.blurb)}

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

${escapeHtml(title)}

${escapeHtml(top.blurb)}

${hasSubs ? `

Narrow by sub-category

${renderSubGrid(slug)}

All ${escapeHtml(title.toLowerCase())}

${rows.length === 0 ? `No listings in ${escapeHtml(title)} yet. Be the first.` : `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'} across ${subs.filter((c) => countsByCategory()[c.slug] > 0).length} sub-categories, newest first.`}

${rows.length === 0 ? '' : renderListingList(rows)}
` : `

${rows.length === 0 ? `No listings in ${escapeHtml(title)} yet. Be the first.` : `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'}, newest first.`}

${rows.length === 0 ? emptyTop(top) : renderListingList(rows)}
`}

← All categories

`, }; } function emptyTop(top) { return `
Nothing here yet. If you ship ${escapeHtml(top.title.toLowerCase())}, the submit flow is live — upload, scan, sign, your listing appears in this view.
`; } /* ------------------------------- 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/ 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: `

${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)}

${backLabel}

`, }; } /* ------------------------------- 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 ? ` ${escapeHtml(chipTarget.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 ${shortenPublisher(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(shortenPublisher(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(shortenPublisher(row.publisher))} — ${/^(bitcoincash|bchtest):q[a-z0-9]{41}$/.test(String(row.publisher)) ? `a raw BCH address publisher. The signing key is this address itself; no BNS name is attached.` : `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.

${chipTarget ? `← Back to ${escapeHtml(chipTarget.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(shortenPublisher(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)}`; } /** * Shorten a publisher for display. BNS names (foo.x) are returned as-is; * raw cashaddresses are collapsed to `bchtest:qpumq…sh478` so they fit a * card without breaking the layout. The URL/path side still uses the full * publisher — this is a display-only transform. */ function shortenPublisher(pub) { const s = String(pub || ''); const m = s.match(/^(bitcoincash|bchtest):(q[a-z0-9]{41})$/); if (!m) return s; const addr = m[2]; return `${m[1]}:${addr.slice(0, 5)}…${addr.slice(-5)}`; } 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 ''; } }