openvibers/openvibe.host Commits

Host home shows the catalog at a glance (counts, the lowest price for each size, the most per dollar, providers compared, the cheapest first-year domains, the questions people ask), and OVHcloud's domain prices join Porkbun's in the comparison (#137)

OP OpenVibers committed 22 days ago · 17296f779c · 9 files, +115 −11

Changes

ddiff --git a/public/css/catalog.css b/public/css/catalog.css
iindex 7959444..605239d 100644
---- a/public/css/catalog.css
++++ b/public/css/catalog.css
@@@ -211,3 +211,28 @@
.hb-table td.hb-gb { grid-column: 2; order: -1; text-align: right; align-self: start; }
.hb-table td.hb-gb::before { display: none; }
}
++
++/* The home's counts, plan and provider cards and questions (server/render/catalog.js frontMore). Theme tokens only;
++ every grid fits a 320px phone. */
++.hh-stats-wrap { margin-top: 32px; }
++.hh-stats { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr)); }
++.hh-stats li { border-radius: 14px; border: 1px solid var(--border, #1f2d47); border-top: 3px solid var(--accent, #3b82f6); background: var(--bg-secondary, #0f1729); }
++.hh-stats a { display: block; height: 100%; box-sizing: border-box; padding: 14px 16px; color: inherit; text-decoration: none; border-radius: 14px; }
++.hh-stats a:hover, .hh-stats a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
++.hh-stats b { display: block; font-size: 1.6rem; line-height: 1.1; font-weight: 800; font-variant-numeric: tabular-nums; }
++.hh-stats span { display: block; margin-top: 4px; font-size: .86rem; color: var(--text-secondary, #96a7c2); }
++.hh-cards { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); }
++.hh-cards a { display: flex; flex-direction: column; gap: 4px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; transition: border-color .15s ease, transform .15s ease; }
++.hh-cards a:hover, .hh-cards a:focus-visible { border-color: var(--accent, #3b82f6); transform: translateY(-1px); outline: 0; }
++.hh-cards b { font-size: 1rem; overflow-wrap: anywhere; }
++.hh-label { font-size: .75rem; font-weight: 700; letter-spacing: .03em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++.hh-spec { font-size: .85rem; color: var(--text-secondary, #96a7c2); }
++.hh-price { font-size: .88rem; color: var(--text-secondary, #96a7c2); }
++.hh-price strong { font-size: 1.2rem; color: var(--text-primary, #e6edf7); font-variant-numeric: tabular-nums; }
++.hh-faq { display: grid; gap: 8px; max-width: 820px; }
++.hh-faq details { border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.hh-faq summary { cursor: pointer; padding: 12px 16px; font-weight: 650; }
++.hh-faq summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 12px; }
++.hh-faq details p { margin: 0; padding: 0 16px 14px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
++@media (prefers-reduced-motion: reduce) { .hh-cards a { transition: none; } .hh-cards a:hover { transform: none; } }
++.hd-front .hc-table-wrap + .hc-table-wrap { margin-top: 18px; }
ddiff --git a/server/app.js b/server/app.js
iindex 4075dd8..7e6d4c6 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -11,6 +11,10 @@ const cache = require('openvibe-shared/cache-policy');
const sharedSeo = require('openvibe-shared/seo');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
++// The home's questions are Host's own FAQ in its service manifest (openvibe-contracts), shown and published as FAQPage.
++const HOST_FAQ = (((contracts.services.manifests.find((m) => m.id === 'host') || {}).site || {}).faq || []);
++const HOME_TIERS_GB = [1, 2, 4, 8, 16, 32];
++const HOME_DESCRIPTION = 'Cloud and VPS plans and domain prices compared side by side, per month, per vCPU and per GB, every price dated and linked to its source.';
const ovServe = require('openvibe-shared/serve');
const { createIndexNow } = require('openvibe-shared/indexnow');
const configLib = require('./config');
@@@ -196,8 +200,22 @@ async function createApp(opts = {}) {
app.get('/updates', (_req, res) => res.type('html').send(renderPage({ title: 'What shipped on OpenVibe.Host', body: frame.updatesBody({ service: 'host', siteName: 'OpenVibe.Host' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, config, path: '/updates', indexable: true })));
app.get('/', viewers.middleware('dashboard'), async (req, res, next) => {
try {
-- const [result, providers, promos, stats, recent, popular, domainStats] = await Promise.all([catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats(), catalog.offers({}), catalog.domains.list({}), catalog.domains.stats()]);
-- res.type('html').send(renderPage({ body: catalogRender.front({ plans: result.plans, providers, promos, stats, offers: recent.offers, domains: domainsRender.frontSection({ popular: popular.tlds, stats: domainStats }) }), viewer: req.viewer, config, path: '/', indexable: true, styles: [showcase.STYLESHEET], css: ['css/catalog.css'], jsonLd: catalogRender.jsonLd.front(config.baseUrl, result.plans) }));
++ const [result, providers, promos, stats, recent, popular, domainStats, cheapDomains, providerCards] = await Promise.all([catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats(), catalog.offers({}), catalog.domains.list({}), catalog.domains.stats(), catalog.domains.list({ sort: 'register' }), catalog.providerSummary()]);
++ // The lowest price for each size, and the most each dollar buys: the same plan query the catalog page runs.
++ const first = async (q) => (await catalog.plans(q)).plans[0] || null;
++ const tiers = (await Promise.all(HOME_TIERS_GB.map(async (gb) => ({ gb, plan: await first({ min_ram_mb: gb * 1024 }) }))))
++ .filter((x, i, all) => x.plan && !(i > 0 && all[i - 1].plan && all[i - 1].plan.id === x.plan.id)); // a size the smaller one already covers adds nothing
++ const [vcpu, ram, disk] = await Promise.all([first({ sort: 'price_per_vcpu' }), first({ sort: 'price_per_gb_ram' }), first({ sort: 'price_per_gb_disk' })]);
++ const more = {
++ counts: [
++ { value: stats.plans, label: 'hosting plans compared', href: '/catalog' },
++ { value: stats.providers, label: stats.providers === 1 ? 'provider' : 'providers', href: '/catalog/providers' },
++ { value: domainStats.tlds, label: 'domain endings priced', href: '/catalog/domains' },
++ { value: (domainStats.registrars || []).length, label: (domainStats.registrars || []).length === 1 ? 'registrar priced' : 'registrars compared', href: '/catalog/domains' },
++ ],
++ tiers, value: { vcpu, ram, disk }, providerCards, faq: HOST_FAQ,
++ };
++ res.type('html').send(renderPage({ title: 'Hosting plans and domain prices, compared', description: HOME_DESCRIPTION, body: catalogRender.front({ plans: result.plans, providers, promos, stats, offers: recent.offers, more, domains: domainsRender.frontSection({ popular: popular.tlds, cheap: cheapDomains.tlds, stats: domainStats }) }), viewer: req.viewer, config, path: '/', indexable: true, styles: [showcase.STYLESHEET], css: ['css/catalog.css'], jsonLd: [...catalogRender.jsonLd.front(config.baseUrl, result.plans), ...(HOST_FAQ.length ? [sharedSeo.jsonLd.faq(HOST_FAQ.map((f) => ({ q: f.question, a: f.answer })))] : [])] }));
} catch (err) { next(err); }
});
app.use((req, res) => req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }) : res.status(404).type('html').send(renderPage({ title: 'Not found', body: '<h1>Not found</h1>', config, path: req.originalUrl })));
ddiff --git a/server/catalog/domains.js b/server/catalog/domains.js
iindex e3cd8b9..c9af0ad 100644
---- a/server/catalog/domains.js
++++ b/server/catalog/domains.js
@@@ -23,8 +23,24 @@ const SOURCES = Object.freeze({
// Handshake names are sold beside ICANN TLDs but resolve only in Handshake's own root.
skip: (x) => x.special_type != null && x.special_type !== '',
},
++ 'ovhcloud-domain-pricing': {
++ slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://www.ovhcloud.com/en/', source_url: 'https://www.ovhcloud.com/en/domains/tld/',
++ // The public catalog's integers are hundred-millionths of a dollar (920000000 is $9.20). A first year of 0 (an
++ // ending sold only under conditions) or the 999999 sentinel is not a price to compare: that ending is left out.
++ prices: (x) => ({ register: hundredMillionths(x.register), renew: hundredMillionths(x.renew), transfer: hundredMillionths(x.transfer, { zero: true }) }),
++ skip: (x) => x.currency !== 'USD' || !(Number(x.register) > 0) || Number(x.register) >= OVH_SENTINEL,
++ },
});
++const OVH_SENTINEL = 99999900000000;
++/** An OVHcloud catalog price → dollars, or null (missing, the sentinel, or 0 where 0 is not a price). */
++function hundredMillionths(v, { zero = false } = {}) {
++ const n = Number(v);
++ if (v == null || v === '' || !Number.isFinite(n) || n < 0 || n >= OVH_SENTINEL) return null;
++ if (n === 0 && !zero) return null;
++ return Math.round(n / 1e6) / 100;
++}
++
function amount(v) {
if (v == null || v === '') return null;
if (typeof v === 'number') return Number.isFinite(v) && v >= 0 ? v : null;
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex bb8fbe3..99befe0 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -246,10 +246,15 @@ function createCatalog({ store, community = null, log = console, indexnow = null
const r = await db.prepare("SELECT COUNT(*) AS plans, COUNT(DISTINCT p.provider_id) AS providers, MAX(p.checked_at) AS last FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active'").get();
return { plans: Number(r.plans) || 0, providers: Number(r.providers) || 0, lastChecked: r.last == null ? null : Number(r.last) };
}
++ /** Each provider with active plans: how many, the lowest monthly price and the newest check (the front page's cards). */
++ async function providerSummary(currency = 'USD') {
++ const rows = await db.prepare("SELECT v.slug, v.name, COUNT(*) AS plans, MIN(p.price_monthly) AS from_price, MAX(p.checked_at) AS last FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' AND p.currency = ? GROUP BY v.slug, v.name ORDER BY MIN(p.price_monthly) ASC, v.name").all(currency);
++ return rows.map((r) => ({ slug: r.slug, name: r.name, plans: Number(r.plans) || 0, from_price: r.from_price == null ? null : Number(r.from_price), currency, last_checked: r.last == null ? null : Number(r.last) }));
++ }
/** Every public plan for a form's choice list: provider by name, then cheapest first. */
async function planChoices() { return await db.prepare("SELECT p.id, p.name, v.name AS provider_name, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY v.name, p.price_monthly, p.id LIMIT 1000").all(); }
// Each public plan with the day its price was last checked, and its provider's slug, so the sitemap can date pages.
async function sitemapPlans() { return await db.prepare("SELECT p.id, p.checked_at, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }
-- return { onChange: (fn) => listeners.push(fn), save, get, applyItem, domains, plans, plan, providers, promos, offers, currencies: currenciesInUse, stats, sitemapPlans, planChoices, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
++ return { onChange: (fn) => listeners.push(fn), save, get, applyItem, domains, plans, plan, providers, promos, offers, currencies: currenciesInUse, stats, providerSummary, sitemapPlans, planChoices, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
}
module.exports = { createCatalog };
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
iindex a6f0b48..0175aa4 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -314,7 +314,30 @@ const jsonLd = {
};
/** The front page of the hosting comparison. */
--function front({ plans, providers, promos: live, stats, offers: recent = [], domains: domainSection = '', now = Date.now() }) {
++/** A plan as a small card: what it is, its size and its price, opening the plan's page. */
++const planCard = (label, p, note = '') => `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span class="hh-label">${esc(label)}</span><b>${esc(p.provider_name)} ${esc(p.name)}</b><span class="hh-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb), sizeGb(p.disk_gb) ? `${sizeGb(p.disk_gb)} disk` : null].filter(Boolean).map(esc).join(' · ')}</span><span class="hh-price"><strong>${esc(money(p.price_monthly, p.currency))}</strong> a month${note ? ` · ${esc(note)}` : ''}</span>`)}</li>`;
++const perUnit = (p, by) => {
++ const price = Number(p.price_monthly);
++ const n = by === 'vcpu' ? Number(p.vcpu) : by === 'ram' ? Number(p.ram_mb) / 1024 : Number(p.disk_gb);
++ return Number.isFinite(price) && n > 0 ? money(price / n, p.currency) : null;
++};
++
++/** The home's extra blocks, each from real rows and left out when it has none. */
++function frontMore({ counts = [], tiers = [], value = {}, providerCards = [], faq = [] }) {
++ const stat = counts.filter((c) => c && c.value);
++ const best = [['vcpu', 'Lowest price per vCPU', 'per vCPU'], ['ram', 'Lowest price per GB of RAM', 'per GB of RAM'], ['disk', 'Lowest price per GB of disk', 'per GB of disk']]
++ .filter(([k]) => value[k]).map(([k, label, unit]) => { const u = perUnit(value[k], k); return planCard(label, value[k], u ? `${u} ${unit}` : ''); });
++ return {
++ stats: stat.length ? `<div class="sc-sec hh-stats-wrap"><ul class="hh-stats" aria-label="OpenVibe.Host in numbers">${stat.map((c) => `<li>${link(c.href, `<b>${esc(c.value.toLocaleString('en'))}</b><span>${esc(c.label)}</span>`)}</li>`).join('')}</ul></div>` : '',
++ sizes: tiers.length ? `<section class="sc-sec" aria-labelledby="hh-sizes"><h2 id="hh-sizes">The lowest price for each size</h2><p class="sc-lede">The cheapest plan with at least this much memory, from every provider in the catalog.</p><ul class="hh-cards">${tiers.map((x) => planCard(`${x.gb} GB of RAM or more`, x.plan)).join('')}</ul></section>` : '',
++ value: best.length ? `<section class="sc-sec" aria-labelledby="hh-value"><h2 id="hh-value">The most for your money</h2><p class="sc-lede">The same catalog sorted by what each dollar buys. ${link('/catalog?sort=price_per_gb_ram', 'Sort every plan this way')}.</p><ul class="hh-cards">${best.join('')}</ul></section>` : '',
++ providers: providerCards.length ? `<section class="sc-sec" aria-labelledby="hh-providers"><h2 id="hh-providers">Providers compared</h2><p class="sc-lede">Each provider's plans in the catalog, from its lowest monthly price, with the day it was last checked.</p><ul class="hh-cards">${providerCards.map((v) => `<li>${link(`/catalog/providers/${encodeURIComponent(v.slug)}`, `<b>${esc(v.name)}</b><span class="hh-spec">${esc(`${v.plans} plan${v.plans === 1 ? '' : 's'}`)}${v.domains ? esc(` · ${v.domains.toLocaleString('en')} domain endings`) : ''}</span><span class="hh-price">from <strong>${esc(money(v.from_price, v.currency))}</strong> a month</span>${v.last_checked ? `<span class="hh-spec">checked ${esc(day(v.last_checked))}</span>` : ''}`)}</li>`).join('')}</ul></section>` : '',
++ faq: faq.length ? `<section class="sc-sec" aria-labelledby="hh-faq"><h2 id="hh-faq">Questions people ask</h2><div class="hh-faq">${faq.map((f) => `<details><summary>${esc(f.question)}</summary><p>${esc(f.answer)}</p></details>`).join('')}</div></section>` : '',
++ };
++}
++
++function front({ plans, providers, promos: live, stats, offers: recent = [], domains: domainSection = '', more = {}, now = Date.now() }) {
++ const extra = frontMore(more);
const aside = plans.length ? `<div class="hc-aside" aria-label="Lowest prices right now">
<p class="hc-aside-h">Lowest monthly prices right now</p>
<ol>${plans.slice(0, 5).map((p) => `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span>${esc(p.provider_name)} <b>${esc(p.name)}</b></span><span class="hc-aside-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb)].filter(Boolean).map(esc).join(' · ')}</span><strong>${esc(money(p.price_monthly, p.currency))}</strong>`)}</li>`).join('')}</ol>
@@@ -328,8 +351,9 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom
actions: [{ label: 'Compare plans', href: '/catalog', primary: true }, { label: 'Domain prices', href: '/catalog/domains' }, { label: 'Latest offers', href: '/catalog/offers' }],
note: stats.plans ? `${stats.plans} plans from ${stats.providers} provider${stats.providers === 1 ? '' : 's'} so far. More providers join as their sources are checked.` : 'The catalog is filling up: providers join as their official sources are checked and enabled.',
aside: aside ? { html: aside } : undefined,
-- }) + `<section class="sc-sec">${planTable(plans.slice(0, 8), { caption: 'Lowest price first' })}
++ }) + extra.stats + `<section class="sc-sec" aria-labelledby="hh-cheapest"><h2 id="hh-cheapest">The lowest monthly prices</h2>${planTable(plans.slice(0, 8), { caption: 'Lowest price first' })}
<p class="hc-more">${link('/catalog', 'Filter and sort every plan →', 'button')}</p></section>`
++ + extra.sizes + extra.value + extra.providers
+ domainSection
+ showcase.features({
id: 'why',
@@@ -342,6 +366,7 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom
],
}) + (recent.length ? `<section class="sc-sec"><h2>Latest hosting offers</h2><ol class="hc-offers">${recent.slice(0, 6).map((o) => offerItem(o, now)).join('')}</ol><p class="hc-more">${link('/catalog/offers', 'Every offer, by price or date →')}</p></section>` : '')
+ (live.length ? `<section class="sc-sec"><h2>Promotions running now</h2>${promoCards(live.slice(0, 3))}<p class="hc-more">${link('/catalog/promos', 'All promotions →')}</p></section>` : '')
++ + extra.faq
+ `<p class="hc-own">Want to host a site with OpenVibe? ${link('https://openvibe.space', 'Visit OpenVibe.Space')}.</p>`;
}
ddiff --git a/server/render/domains.js b/server/render/domains.js
iindex 1032164..c1bd6c5 100644
---- a/server/render/domains.js
++++ b/server/render/domains.js
@@@ -155,11 +155,13 @@ ${alts}
<p class="hc-links">${link('/catalog/domains', '← Compare every ending')}</p>`;
}
--function frontSection({ popular = [], stats }) {
++function frontSection({ popular = [], cheap = [], stats }) {
++ const registrars = (stats.registrars || []).length;
return `<section class="sc-sec hd-front" aria-labelledby="hd-front-h"><h2 id="hd-front-h">Domain names, compared too</h2>
--<p class="hc-dim">${stats.tlds ? `${stats.tlds.toLocaleString('en')} endings, with the first year, the renewal and five years side by side. ` : ''}Check a name at its registry, then register it where it costs least.</p>
++<p class="hc-dim">${stats.tlds ? `${stats.tlds.toLocaleString('en')} endings${registrars > 1 ? ` from ${registrars} registrars (${stats.registrars.map(esc).join(', ')})` : ''}, with the first year, the renewal and five years side by side. ` : ''}Check a name at its registry, then register it where it costs least.</p>
${checkForm({})}
${tldTable(popular.slice(0, 6), { caption: 'Popular endings: the cheapest price at any registrar' })}
++${cheap.length ? tldTable(cheap.slice(0, 6), { caption: 'The cheapest first year right now: check what it renews at' }) : ''}
${stats.tlds ? `<p class="hc-more">${link('/catalog/domains', 'Every ending, by price →', 'button')}</p>` : ''}</section>`;
}
ddiff --git a/test/catalog-domains.test.js b/test/catalog-domains.test.js
iindex 6aaf3c6..969b299 100644
---- a/test/catalog-domains.test.js
++++ b/test/catalog-domains.test.js
@@@ -57,6 +57,15 @@ function fakeRegistry() {
const v = domains.mapTldPrice(item('security', price('2,060.25')));
assert.deepStrictEqual([v.tld, v.register, v.renew, v.currency, v.provider.slug], ['security', 2060.25, 2060.25, 'USD', 'porkbun']);
assert.strictEqual(domains.mapTldPrice({ source_key: 'vultr-plans' }), null, 'a plan source is not a price list');
++ // OVHcloud's public catalog: hundred-millionths of a dollar; a first year of 0 or the sentinel is not sold that way.
++ const ovh = (tld, fields) => ({ ...item(tld, {}), source_key: 'ovhcloud-domain-pricing', fields: { currency: 'USD', ...fields } });
++ const com = domains.mapTldPrice(ovh('com', { register: 920000000, renew: 1649000000, transfer: 920000000 }));
++ assert.deepStrictEqual([com.tld, com.register, com.renew, com.transfer, com.provider.slug, com.source_url], ['com', 9.2, 16.49, 9.2, 'ovhcloud', 'https://www.ovhcloud.com/en/domains/tld/']);
++ assert.strictEqual(domains.mapTldPrice(ovh('co.uk', { register: 849000000, renew: 949000000, transfer: 0 })).transfer, 0, 'a free transfer is a price of 0');
++ assert.strictEqual(domains.mapTldPrice(ovh('br', { register: 0, renew: 100000000, transfer: 0 })), null, 'a first year of 0 is not a price to compare');
++ assert.strictEqual(domains.mapTldPrice(ovh('tn', { register: 99999900000000, renew: 99999900000000 })), null, 'the premium sentinel is never a price');
++ assert.strictEqual(domains.mapTldPrice(ovh('com', { currency: 'EUR', register: 920000000 })), null, 'only US dollars are compared');
++ assert.strictEqual(domains.mapTldPrice(ovh('io', { register: 6299000000, renew: 99999900000000 })).renew, null, 'a sentinel renewal is unknown, not a price');
});
await check('RDAP: registered, unregistered, rate-limited, no RDAP; https only, no redirects, answers cached', async () => {
ddiff --git a/test/catalog-public.test.js b/test/catalog-public.test.js
iindex 2f2d934..7c190fe 100644
---- a/test/catalog-public.test.js
++++ b/test/catalog-public.test.js
@@@ -42,6 +42,10 @@ const { boot, check, done } = require('./helpers/boot');
const front = await t.api('GET', '/'); assert.strictEqual(front.status, 200);
assert.match(front.text, /Lowest monthly prices right now/); assert.match(front.text, /\$4\.00/, 'the cheapest plan is on the front page');
assert.match(front.text, /css\/catalog\.css\?v=/);
++ for (const h of ['The lowest monthly prices', 'The lowest price for each size', 'The most for your money', 'Providers compared', 'Questions people ask']) assert.ok(front.text.includes(h), `the front page has "${h}"`);
++ assert.match(front.text, /<title>Hosting plans and domain prices, compared · OpenVibe\.Host<\/title>/);
++ assert.match(front.text, /<ul class="hh-stats"[^>]*><li><a href="\/catalog"><b>\d+<\/b><span>hosting plans compared<\/span>/, 'the counts are real and link where they come from');
++ assert.ok(front.text.includes(`href="/catalog/providers/${p.slug}"`), 'a provider card opens its page');
assert.strictEqual((await t.api('GET', '/catalog/providers')).status, 200);
const nothing = await t.api('GET', '/catalog?min_vcpu=999'); assert.match(nothing.text, /No plan matches these filters/);
const bad = await t.api('GET', '/catalog?sort=affiliate'); assert.strictEqual(bad.status, 422);
@@@ -49,7 +53,7 @@ const { boot, check, done } = require('./helpers/boot');
// a Product with a monthly Offer on a plan; never on a filtered (noindex) view.
const ld = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])).flat();
const types = (html) => ld(html).map((x) => x['@type']);
-- assert.ok(types(front.text).includes('WebSite') && types(front.text).includes('ItemList'), JSON.stringify(types(front.text)));
++ assert.ok(types(front.text).includes('WebSite') && types(front.text).includes('ItemList') && types(front.text).includes('FAQPage'), JSON.stringify(types(front.text)));
assert.ok(types(plans.text).includes('ItemList'));
const planPage = await t.api('GET', `/catalog/plans/${cheap.id}`);
const product = ld(planPage.text).find((x) => x['@type'] === 'Product');
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex d329661..6a0cfae 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -12,13 +12,13 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 26, // measured 23.8 (fresh database; 15.9 before the showcase front page, 2026-10-07)
-- htmlBrotliKB: 7, // 6.3 (4.3 before the showcase)
++ htmlRawKB: 32, // 28.8 on 2026-10-22 (fresh database: the curated front page's counts, sizes, value, providers and questions); 23.8 before; 15.9 before the showcase front page, 2026-10-07
++ htmlBrotliKB: 8, // 7.1 on 2026-10-22; 6.3 (4.3 before the showcase)
jsFiles: 5, // 5 (openvibe-shared 2.10's shell adds web-runtime.js)
jsRawKB: 250, // 239.1 (openvibe-shared 2.6.0 served scripts; was 212.2 on 2.2.0)
jsBrotliKB: 60, // 56.3 (was 49.9)
cssFiles: 3, // 3 (host.css, openvibe-shared showcase.css, catalog.css: the front page is the hosting catalog since Host#69)
-- cssRawKB: 33, // 31.9 (2026-10-16 the domain comparison's and benchmarks' styles; 27.2 with showcase.css 4.1.5, 14.8 before catalog.css)
++ cssRawKB: 38, // 34.8 on 2026-10-22 (the front page's cards and questions); 31.9 (2026-10-16 the domain comparison's and benchmarks' styles; 27.2 with showcase.css 4.1.5, 14.8 before catalog.css)
cssBrotliKB: 7.5, // 6.9 (5.7 before the domain and benchmark styles, 3.6 before catalog.css)
externalFiles: 1, // 0
};