openvibers/openvibe.host Commits
Host: JSON-LD on the catalog pages (WebSite, ItemList, Product with a monthly Offer) (#76)
Changes
@@@ -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.');@@@ -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));}));@@@ -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 };@@@ -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' }),@@@ -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(); }
@@ -37,9 +37,9 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
@@ -37,9 +37,9 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
res.vary('Cookie'); res.vary('Authorization');
res.vary('Cookie'); res.vary('Authorization');
res.set('Cache-Control', cache.htmlHeaders(publicPage && req.viewer.kind === 'anonymous' ? { maxAge: 60 } : { private: true }));
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);
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) {
async function discussionFor(req, type, entity, label) {
if (!community || !community.enabled) return null;
if (!community || !community.enabled) return null;
@@ -66,7 +66,8 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
@@ -66,7 +66,8 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
router.get('/', view(async (req, res) => {
router.get('/', view(async (req, res) => {
const [result, providers, currencies] = await Promise.all([catalog.plans(req.query), catalog.providers(), catalog.currencies()]);
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,
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) => {
router.get('/providers', view(async (req, res) => {
page(req, res, 'Hosting providers', render.providerList(await catalog.providers()));
page(req, res, 'Hosting providers', render.providerList(await catalog.providers()));
@@ -79,7 +80,7 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
@@ -79,7 +80,7 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
router.get('/plans/:id', view(async (req, res) => {
router.get('/plans/:id', view(async (req, res) => {
const p = await catalog.plan(req.params.id);
const p = await catalog.plan(req.params.id);
const discussion = await discussionFor(req, 'plan', p, `${p.provider_name} ${p.name}`);
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) => {
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.');
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.');
@@ -31,8 +31,8 @@ function createDashboard(ctx) {
@@ -31,8 +31,8 @@ function createDashboard(ctx) {
if (ctx.actorLimits) router.use(ctx.actorLimits);
if (ctx.actorLimits) router.use(ctx.actorLimits);
router.use((req, res, next) => { privateNoStore(res); next(); });
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) {
function fail(req, res, err) {
@@ -81,7 +81,7 @@ function createDashboard(ctx) {
@@ -81,7 +81,7 @@ function createDashboard(ctx) {
if (req.viewer.kind !== 'user') {
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()]);
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,
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));
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));
}));
}));
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
* order never depends on affiliation (the store sorts; nothing here reorders).
* order never depends on affiliation (the store sorts; nothing here reorders).
*/
*/
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+const seo = require('openvibe-shared/seo');
const { esc } = require('./layout');
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.';
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>` : ''}
@@ -217,6 +218,32 @@ ${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}
<p class="hc-links">${link('/catalog', '← Compare plans')}</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). */
/** 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 }) {
function front({ plans, providers, promos: live, stats, hostingLaunched }) {
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">
@@ -251,4 +278,4 @@ function front({ plans, providers, promos: live, stats, hostingLaunched }) {
@@ -251,4 +278,4 @@ function front({ plans, providers, promos: live, stats, hostingLaunched }) {
<p>${link('/hosting', 'How it works and its limits →')}</p></section>`;
<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 };
@@ -75,6 +75,7 @@ function renderPage(o) {
@@ -75,6 +75,7 @@ function renderPage(o) {
description: o.description || DESCRIPTION,
description: o.description || DESCRIPTION,
canonical,
canonical,
robots: o.indexable ? 'index, follow' : 'noindex, nofollow',
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),
navbar: nav, footer, home: '/', navLinks: NAV_LINKS(signedIn),
head: [
head: [
appIcon.headTags({ site: 'host' }),
appIcon.headTags({ site: 'host' }),
@@ -43,6 +43,16 @@ const { boot, check, done } = require('./stageb/boot');
@@ -43,6 +43,16 @@ const { boot, check, done } = require('./stageb/boot');
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);
+ // 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);
const promos = await t.api('GET', '/api/v1/catalog/promos'); assert.strictEqual(promos.json().promos.length, 1);
});
});
} finally { await t.close(); }
} finally { await t.close(); }