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)
Changes
@@@ -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; }@@@ -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 })));@@@ -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;@@@ -246,10 +246,15 @@ function createCatalog({ store, community = null, log = console, indexnow = nullconst 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 };@@@ -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 = [], domactions: [{ 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>`;}@@@ -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>`;}@@@ -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 () => {@@@ -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');@@@ -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};
@@ -211,3 +211,28 @@
@@ -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 { grid-column: 2; order: -1; text-align: right; align-self: start; }
.hb-table td.hb-gb::before { display: none; }
.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; }
@@ -11,6 +11,10 @@ const cache = require('openvibe-shared/cache-policy');
@@ -11,6 +11,10 @@ const cache = require('openvibe-shared/cache-policy');
const sharedSeo = require('openvibe-shared/seo');
const sharedSeo = require('openvibe-shared/seo');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
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 ovServe = require('openvibe-shared/serve');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createIndexNow } = require('openvibe-shared/indexnow');
const configLib = require('./config');
const configLib = require('./config');
@@ -196,8 +200,22 @@ async function createApp(opts = {}) {
@@ -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('/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) => {
app.get('/', viewers.middleware('dashboard'), async (req, res, next) => {
try {
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); }
} 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 })));
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 })));
@@ -23,8 +23,24 @@ const SOURCES = Object.freeze({
@@ -23,8 +23,24 @@ const SOURCES = Object.freeze({
// Handshake names are sold beside ICANN TLDs but resolve only in Handshake's own root.
// Handshake names are sold beside ICANN TLDs but resolve only in Handshake's own root.
skip: (x) => x.special_type != null && x.special_type !== '',
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) {
function amount(v) {
if (v == null || v === '') return null;
if (v == null || v === '') return null;
if (typeof v === 'number') return Number.isFinite(v) && v >= 0 ? v : null;
if (typeof v === 'number') return Number.isFinite(v) && v >= 0 ? v : null;
@@ -246,10 +246,15 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -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();
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) };
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. */
/** 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(); }
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.
// 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(); }
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 };
module.exports = { createCatalog };
@@ -314,7 +314,30 @@ const jsonLd = {
@@ -314,7 +314,30 @@ const jsonLd = {
};
};
/** The front page of the hosting comparison. */
/** 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">
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>
<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>
<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
@@ -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' }],
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.',
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,
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>`
<p class="hc-more">${link('/catalog', 'Filter and sort every plan →', 'button')}</p></section>`
+ + extra.sizes + extra.value + extra.providers
+ domainSection
+ domainSection
+ showcase.features({
+ showcase.features({
id: 'why',
id: 'why',
@@ -342,6 +366,7 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom
@@ -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>` : '')
}) + (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>` : '')
+ (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>`;
+ `<p class="hc-own">Want to host a site with OpenVibe? ${link('https://openvibe.space', 'Visit OpenVibe.Space')}.</p>`;
}
}
@@ -155,11 +155,13 @@ ${alts}
@@ -155,11 +155,13 @@ ${alts}
<p class="hc-links">${link('/catalog/domains', '← Compare every ending')}</p>`;
<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>
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({})}
${checkForm({})}
${tldTable(popular.slice(0, 6), { caption: 'Popular endings: the cheapest price at any registrar' })}
${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>`;
${stats.tlds ? `<p class="hc-more">${link('/catalog/domains', 'Every ending, by price →', 'button')}</p>` : ''}</section>`;
}
}
@@ -57,6 +57,15 @@ function fakeRegistry() {
@@ -57,6 +57,15 @@ function fakeRegistry() {
const v = domains.mapTldPrice(item('security', price('2,060.25')));
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.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');
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 () => {
await check('RDAP: registered, unregistered, rate-limited, no RDAP; https only, no redirects, answers cached', async () => {
@@ -42,6 +42,10 @@ const { boot, check, done } = require('./helpers/boot');
@@ -42,6 +42,10 @@ const { boot, check, done } = require('./helpers/boot');
const front = await t.api('GET', '/'); assert.strictEqual(front.status, 200);
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, /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=/);
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);
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 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);
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');
@@ -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.
// 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 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']);
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'));
assert.ok(types(plans.text).includes('ItemList'));
const planPage = await t.api('GET', `/catalog/plans/${cheap.id}`);
const planPage = await t.api('GET', `/catalog/plans/${cheap.id}`);
const product = ld(planPage.text).find((x) => x['@type'] === 'Product');
const product = ld(planPage.text).find((x) => x['@type'] === 'Product');
@@ -12,13 +12,13 @@ const { spawn } = require('child_process');
@@ -12,13 +12,13 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
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)
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)
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)
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)
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)
cssBrotliKB: 7.5, // 6.9 (5.7 before the domain and benchmark styles, 3.6 before catalog.css)
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};