openvibers/openvibe.host Commits

Host: JSON-LD on the catalog pages (WebSite, ItemList, Product with a monthly Offer) (#76)

OP OpenVibers committed 12 Oct 2026 · e35845ceb4 · 5 files, +47 −8

JSON-LD for the hosting catalog's indexable pages (the SEO survey found none on openvibe.host):
- the front page carries `WebSite` and an `ItemList` of the lowest monthly prices;
- `/catalog` carries an `ItemList` of the plans shown;
- a plan page carries `BreadcrumbList` and a `Product` whose `Offer` states the monthly price (a `UnitPriceSpecification` per MONTH) and links the provider's source page.

`renderPage` passes JSON-LD only for indexable pages, so a filtered `/catalog?…` view (noindex) carries none.

Test: `catalog-public.test.js` checks the types, the plan's Offer, and the filtered-view case. The discussion, dashboard and launch-switch tests pass.

Changes

ddiff --git a/server/http/catalog.js b/server/http/catalog.js
iindex 24abbfa..1984c31 100644
---- a/server/http/catalog.js
++++ b/server/http/catalog.js
@@@ -37,9 +37,9 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
res.vary('Cookie'); res.vary('Authorization');
res.set('Cache-Control', cache.htmlHeaders(publicPage && req.viewer.kind === 'anonymous' ? { maxAge: 60 } : { private: true }));
};
-- const page = (req, res, title, body, indexable = true, description) => {
++ const page = (req, res, title, body, indexable = true, description, ld) => {
cachePage(req, res, indexable);
-- return res.status(200).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, indexable, description, css: ['css/catalog.css'] }));
++ return res.status(200).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, indexable, description, jsonLd: ld, css: ['css/catalog.css'] }));
};
async function discussionFor(req, type, entity, label) {
if (!community || !community.enabled) return null;
@@@ -66,7 +66,8 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
router.get('/', view(async (req, res) => {
const [result, providers, currencies] = await Promise.all([catalog.plans(req.query), catalog.providers(), catalog.currencies()]);
page(req, res, 'Compare hosting plans', render.catalog({ result, query: req.query, providers, currencies }), !Object.keys(req.query).length,
-- 'Compare cloud, VPS and dedicated hosting plans: price per month, per vCPU and per GB, each with the day it was checked and its source.');
++ 'Compare cloud, VPS and dedicated hosting plans: price per month, per vCPU and per GB, each with the day it was checked and its source.',
++ render.jsonLd.catalog(config.baseUrl, result.plans));
}));
router.get('/providers', view(async (req, res) => {
page(req, res, 'Hosting providers', render.providerList(await catalog.providers()));
@@@ -79,7 +80,7 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
router.get('/plans/:id', view(async (req, res) => {
const p = await catalog.plan(req.params.id);
const discussion = await discussionFor(req, 'plan', p, `${p.provider_name} ${p.name}`);
-- page(req, res, `${p.provider_name} ${p.name}`, render.plan(p, discussion), true, `${p.provider_name} ${p.name}: ${p.currency} ${p.price_monthly} a month, with its specifications, price per resource and price history.`);
++ page(req, res, `${p.provider_name} ${p.name}`, render.plan(p, discussion), true, `${p.provider_name} ${p.name}: ${p.currency} ${p.price_monthly} a month, with its specifications, price per resource and price history.`, render.jsonLd.plan(config.baseUrl, p));
}));
router.get('/promos', view(async (req, res) => {
page(req, res, 'Hosting promotions', render.promos(await catalog.promos()), true, 'Hosting promotions running now, with their codes and end dates as the providers state them.');
ddiff --git a/server/http/dashboard.js b/server/http/dashboard.js
iindex 4a348fc..caecf0a 100644
---- a/server/http/dashboard.js
++++ b/server/http/dashboard.js
@@@ -31,8 +31,8 @@ function createDashboard(ctx) {
if (ctx.actorLimits) router.use(ctx.actorLimits);
router.use((req, res, next) => { privateNoStore(res); next(); });
-- function page(req, res, status, title, body, notice, { indexable = false, styles, css } = {}) {
-- res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice, indexable, styles, css }));
++ function page(req, res, status, title, body, notice, { indexable = false, styles, css, jsonLd } = {}) {
++ res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice, indexable, styles, css, jsonLd }));
}
function fail(req, res, err) {
@@@ -81,7 +81,7 @@ function createDashboard(ctx) {
if (req.viewer.kind !== 'user') {
const [result, providers, promos, stats] = await Promise.all([ctx.catalog.plans({}), ctx.catalog.providers(), ctx.catalog.promos(), ctx.catalog.stats()]);
return page(req, res, 200, null, catalogPages.front({ plans: result.plans, providers, promos, stats, hostingLaunched: config.publicHosting }), null,
-- { indexable: !req.query || !Object.keys(req.query).length, styles: [showcase.STYLESHEET], css: ['css/catalog.css'] });
++ { indexable: !req.query || !Object.keys(req.query).length, styles: [showcase.STYLESHEET], css: ['css/catalog.css'], jsonLd: catalogPages.jsonLd.front(config.baseUrl, result.plans) });
}
page(req, res, 200, 'Projects', pages.home({ projects: await projects.listFor(req.viewer), csrf: csrfToken(config, req.viewer), canCreate: config.publicHosting || Boolean(req.viewer.staff) }), notice(req));
}));
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
iindex 500ff2b..de6b74a 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -14,6 +14,7 @@
* order never depends on affiliation (the store sorts; nothing here reorders).
*/
const showcase = require('openvibe-shared/showcase');
++const seo = require('openvibe-shared/seo');
const { esc } = require('./layout');
const DISCLOSURE = 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.';
@@@ -217,6 +218,32 @@ ${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}
<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;
}
++/** JSON-LD for the catalog pages (only indexable pages carry it; renderPage drops it otherwise). */
++const planUrl = (base, p) => `${base}/catalog/plans/${encodeURIComponent(p.id)}`;
++const jsonLd = {
++ front(base, plans) {
++ return [
++ seo.jsonLd.website({ name: 'OpenVibe.Host', url: `${base}/`, description: 'Hosting plans compared: price per month, per vCPU and per GB, every price dated and sourced.' }),
++ seo.jsonLd.itemList('Lowest monthly hosting prices', plans.slice(0, 8).map((p) => ({ name: `${p.provider_name} ${p.name}`, url: planUrl(base, p) })), { url: `${base}/catalog` }),
++ ];
++ },
++ catalog(base, plans) {
++ return [seo.jsonLd.itemList('Hosting plans', plans.map((p) => ({ name: `${p.provider_name} ${p.name}`, url: planUrl(base, p) })), { url: `${base}/catalog` })];
++ },
++ plan(base, p) {
++ const price = Number(p.price_monthly);
++ return [
++ seo.jsonLd.breadcrumbs([{ name: 'Catalog', url: `${base}/catalog` }, { name: p.provider_name, url: `${base}/catalog/providers/${encodeURIComponent(p.provider_slug)}` }, { name: p.name, url: planUrl(base, p) }]),
++ seo.jsonLd.product({
++ name: `${p.provider_name} ${p.name}`, url: planUrl(base, p), brand: { '@type': 'Brand', name: p.provider_name },
++ description: [p.vcpu != null ? `${Number(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb) ? `${bytesMb(p.ram_mb)} RAM` : null, sizeGb(p.disk_gb) ? `${sizeGb(p.disk_gb)} disk` : null].filter(Boolean).join(', ') || undefined,
++ offers: Number.isFinite(price) ? { '@type': 'Offer', price: price.toFixed(2), priceCurrency: p.currency, url: p.source_url, availability: 'https://schema.org/InStock',
++ priceSpecification: { '@type': 'UnitPriceSpecification', price: price.toFixed(2), priceCurrency: p.currency, unitText: 'MONTH' } } : undefined,
++ }),
++ ];
++ },
++};
++
/** The signed-out front page: the comparison first, then OpenVibe's own static hosting (still in review). */
function front({ plans, providers, promos: live, stats, hostingLaunched }) {
const aside = plans.length ? `<div class="hc-aside" aria-label="Lowest prices right now">
@@@ -251,4 +278,4 @@ function front({ plans, providers, promos: live, stats, hostingLaunched }) {
<p>${link('/hosting', 'How it works and its limits →')}</p></section>`;
}
--module.exports = { front, catalog, providerList, provider, plan, promos, planTable, DISCLOSURE, money, bytesMb, sizeGb };
++module.exports = { front, catalog, providerList, provider, plan, promos, planTable, jsonLd, DISCLOSURE, money, bytesMb, sizeGb };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex c328b9f..a510e5e 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -75,6 +75,7 @@ function renderPage(o) {
description: o.description || DESCRIPTION,
canonical,
robots: o.indexable ? 'index, follow' : 'noindex, nofollow',
++ jsonLd: o.indexable && Array.isArray(o.jsonLd) ? o.jsonLd : undefined,
navbar: nav, footer, home: '/', navLinks: NAV_LINKS(signedIn),
head: [
appIcon.headTags({ site: 'host' }),
ddiff --git a/test/catalog-public.test.js b/test/catalog-public.test.js
iindex cf6346b..7c94a98 100644
---- a/test/catalog-public.test.js
++++ b/test/catalog-public.test.js
@@@ -43,6 +43,16 @@ const { boot, check, done } = require('./stageb/boot');
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);
++ // JSON-LD on the indexable catalog pages: WebSite and an ItemList on the front, an ItemList on /catalog,
++ // 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(plans.text).includes('ItemList'));
++ const planPage = await t.api('GET', `/catalog/plans/${cheap.id}`);
++ const product = ld(planPage.text).find((x) => x['@type'] === 'Product');
++ assert.ok(product && product.offers.price === '4.00' && product.offers.priceCurrency === 'USD' && product.offers.priceSpecification.unitText === 'MONTH');
++ assert.ok(!types((await t.api('GET', '/catalog?min_vcpu=2')).text).includes('ItemList'), 'a filtered view carries no JSON-LD');
const promos = await t.api('GET', '/api/v1/catalog/promos'); assert.strictEqual(promos.json().promos.length, 1);
});
} finally { await t.close(); }