openvibers/openvibe.community Commits
Share the content-hashed Community card (#255)
Changes
@@@ -14,12 +14,14 @@ const appIcon = require('openvibe-shared/app-icon');const fs = require('fs');const path = require('path');const config = require('../config');++const social = require('openvibe-shared/social');const { escapeHtml } = require('./highlight');const SITE_NAME = 'OpenVibe.Community';const NETWORK_URL = 'https://openvibe.network';const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, forums and threads, and submissions. Free speech within the rules.';--const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;++// The site's content-hashed share card (openvibe-shared/social), served from this site's own /shared copy.++const DEFAULT_OG_IMAGE = `${config.baseUrl}${social.url('community')}`;// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up// every deploy (the same scheme Live uses in server/web/assets.js).@@@ -120,6 +122,7 @@ function renderPage(o) {const nav = navbarInit(o);const foot = footerInit(o);const feeds = o.feeds || [{ title: `${SITE_NAME} — latest pastes`, href: '/feed.xml' }];++ const image = o.ogImage || DEFAULT_OG_IMAGE;// shell.page writes the document, the SEO head and ai-summary, the theme-loader, navbar.js/footer.js// with the navbar init, the noscript nav and the SSR footer; the rest of the head is this site's own.@@@ -128,7 +131,7 @@ function renderPage(o) {title: o.title || `${SITE_NAME} — the people of OpenVibe`,titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',-- type: o.ogType || 'website', image: o.ogImage || DEFAULT_OG_IMAGE, imageAlt: o.imageAlt,++ type: o.ogType || 'website', image, imageAlt: o.imageAlt,jsonLd: o.jsonLd, alternates: o.alternates,// The page names its Markdown twin (openvibe-shared/agent-layer) in the head; only pages whose// twin is mounted set o.agentLayer (the home, /pastes and a paste).@@@ -140,6 +143,8 @@ function renderPage(o) {o.published ? `<meta property="article:published_time" content="${escapeHtml(o.published)}">` : '',o.modified ? `<meta property="article:modified_time" content="${escapeHtml(o.modified)}">` : '',appIcon.headTags({ site: 'community', iconBase: '/assets' }),++ // The shared card is 1200×630; a page's own image carries its own dimensions.++ image === DEFAULT_OG_IMAGE ? '<meta property="og:image:width" content="1200">\n<meta property="og:image:height" content="630">' : '',...feeds.map((f) => `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(f.title)}" href="${escapeHtml(f.href)}">`),...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),@@@ -167,8 +172,7 @@ document.addEventListener('DOMContentLoaded', function () {});</script>`,});-- // The default share image is a small card: only a page's own image gets the large Twitter card.-- return o.ogImage ? html : html.replace('<meta name="twitter:card" content="summary_large_image">', '<meta name="twitter:card" content="summary">');++ return html;}/**@@@ -84,7 +84,9 @@ const { check, done } = require('./helpers/app');assert.strictEqual((head.match(/<title>/g) || []).length, 1);assert.strictEqual((head.match(/<link rel="canonical" href="https:\/\/openvibe.community\/p\/x">/g) || []).length, 1);assert.ok(head.includes('<meta property="og:type" content="article">'));-- assert.ok(head.includes('<meta name="twitter:card" content="summary">'), 'no large card without a page image');++ assert.ok(head.includes('<meta name="twitter:card" content="summary_large_image">'), 'the content-hashed site card is large');++ assert.match(head, /<meta property="og:image" content="https:\/\/openvibe\.community\/shared\/social\/community\.jpg\?v=[0-9a-f]{12}">/);++ assert.ok(head.includes('<meta property="og:image:width" content="1200">') && head.includes('<meta property="og:image:height" content="630">'));assert.ok(head.includes('<meta name="ai-summary" content="A paste.">'));assert.ok(head.includes('<meta property="article:published_time" content="2026-09-15T10:00:00.000Z">'));assert.ok(head.includes('"name":"\\u003c/script>\\u003cb>"') && !head.includes('</script><b>'));@@@ -139,6 +139,22 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual((head.match(/<link rel="canonical"/g) || []).length, 1, 'one canonical');});++ await check('the home page shares the site card, content-hashed, with its 1200×630 size and the Twitter mirror', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ const head = r.text.split('</head>')[0];++ const image = /<meta property="og:image" content="([^"]+)">/.exec(head)?.[1] || '';++ assert.match(image, /^https:\/\/openvibe\.community\/shared\/social\/community\.jpg\?v=[0-9a-f]{12}$/);++ assert.ok(head.includes('<meta property="og:image:width" content="1200">'));++ assert.ok(head.includes('<meta property="og:image:height" content="630">'));++ assert.ok(head.includes('<meta name="twitter:card" content="summary_large_image">'));++ assert.ok(head.includes(`<meta name="twitter:image" content="${image}">`));++ const path = new URL(image).pathname + new URL(image).search;++ const card = await t.get(path);++ assert.strictEqual(card.status, 200, path);++ assert.ok(card.headers.get('content-type').startsWith('image/jpeg'));++ });++await check('HTML pages carry the private page policy; raw and download never reach a shared cache', async () => {for (const path of ['/', '/pastes', '/p/amber-fox-42', '/updates', '/nope-not-a-page']) {const r = await t.get(path);@@@ -26,7 +26,7 @@ const { boot, check, done } = require('./helpers/app');has(r.text, '<title>OpenVibe.Community: pastes, forums and the OpenVibe roadmap</title>');has(r.text, '<link rel="canonical" href="https://openvibe.community/">');has(r.text, '<meta property="og:type" content="website">');-- has(r.text, '<meta name="twitter:card" content="summary">');++ has(r.text, '<meta name="twitter:card" content="summary_large_image">');has(r.text, '"@type":"WebSite"');has(r.text, '/shared/theme-loader.js?v=');has(r.text, '/shared/navbar.js?v=');
@@ -14,12 +14,14 @@ const appIcon = require('openvibe-shared/app-icon');
@@ -14,12 +14,14 @@ const appIcon = require('openvibe-shared/app-icon');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const config = require('../config');
const config = require('../config');
+const social = require('openvibe-shared/social');
const { escapeHtml } = require('./highlight');
const { escapeHtml } = require('./highlight');
const SITE_NAME = 'OpenVibe.Community';
const SITE_NAME = 'OpenVibe.Community';
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, forums and threads, and submissions. Free speech within the rules.';
const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, forums and threads, and submissions. Free speech within the rules.';
-const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;
+// The site's content-hashed share card (openvibe-shared/social), served from this site's own /shared copy.
+const DEFAULT_OG_IMAGE = `${config.baseUrl}${social.url('community')}`;
// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up
// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up
// every deploy (the same scheme Live uses in server/web/assets.js).
// every deploy (the same scheme Live uses in server/web/assets.js).
@@ -120,6 +122,7 @@ function renderPage(o) {
@@ -120,6 +122,7 @@ function renderPage(o) {
const nav = navbarInit(o);
const nav = navbarInit(o);
const foot = footerInit(o);
const foot = footerInit(o);
const feeds = o.feeds || [{ title: `${SITE_NAME} — latest pastes`, href: '/feed.xml' }];
const feeds = o.feeds || [{ title: `${SITE_NAME} — latest pastes`, href: '/feed.xml' }];
+ const image = o.ogImage || DEFAULT_OG_IMAGE;
// shell.page writes the document, the SEO head and ai-summary, the theme-loader, navbar.js/footer.js
// shell.page writes the document, the SEO head and ai-summary, the theme-loader, navbar.js/footer.js
// with the navbar init, the noscript nav and the SSR footer; the rest of the head is this site's own.
// with the navbar init, the noscript nav and the SSR footer; the rest of the head is this site's own.
@@ -128,7 +131,7 @@ function renderPage(o) {
@@ -128,7 +131,7 @@ function renderPage(o) {
title: o.title || `${SITE_NAME} — the people of OpenVibe`,
title: o.title || `${SITE_NAME} — the people of OpenVibe`,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',
siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',
- type: o.ogType || 'website', image: o.ogImage || DEFAULT_OG_IMAGE, imageAlt: o.imageAlt,
+ type: o.ogType || 'website', image, imageAlt: o.imageAlt,
jsonLd: o.jsonLd, alternates: o.alternates,
jsonLd: o.jsonLd, alternates: o.alternates,
// The page names its Markdown twin (openvibe-shared/agent-layer) in the head; only pages whose
// The page names its Markdown twin (openvibe-shared/agent-layer) in the head; only pages whose
// twin is mounted set o.agentLayer (the home, /pastes and a paste).
// twin is mounted set o.agentLayer (the home, /pastes and a paste).
@@ -140,6 +143,8 @@ function renderPage(o) {
@@ -140,6 +143,8 @@ function renderPage(o) {
o.published ? `<meta property="article:published_time" content="${escapeHtml(o.published)}">` : '',
o.published ? `<meta property="article:published_time" content="${escapeHtml(o.published)}">` : '',
o.modified ? `<meta property="article:modified_time" content="${escapeHtml(o.modified)}">` : '',
o.modified ? `<meta property="article:modified_time" content="${escapeHtml(o.modified)}">` : '',
appIcon.headTags({ site: 'community', iconBase: '/assets' }),
appIcon.headTags({ site: 'community', iconBase: '/assets' }),
+ // The shared card is 1200×630; a page's own image carries its own dimensions.
+ image === DEFAULT_OG_IMAGE ? '<meta property="og:image:width" content="1200">\n<meta property="og:image:height" content="630">' : '',
...feeds.map((f) => `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(f.title)}" href="${escapeHtml(f.href)}">`),
...feeds.map((f) => `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(f.title)}" href="${escapeHtml(f.href)}">`),
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),
...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),
...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),
@@ -167,8 +172,7 @@ document.addEventListener('DOMContentLoaded', function () {
@@ -167,8 +172,7 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
</script>`,
</script>`,
});
});
- // The default share image is a small card: only a page's own image gets the large Twitter card.
- return o.ogImage ? html : html.replace('<meta name="twitter:card" content="summary_large_image">', '<meta name="twitter:card" content="summary">');
+ return html;
}
}
/**
/**
@@ -84,7 +84,9 @@ const { check, done } = require('./helpers/app');
@@ -84,7 +84,9 @@ const { check, done } = require('./helpers/app');
assert.strictEqual((head.match(/<title>/g) || []).length, 1);
assert.strictEqual((head.match(/<title>/g) || []).length, 1);
assert.strictEqual((head.match(/<link rel="canonical" href="https:\/\/openvibe.community\/p\/x">/g) || []).length, 1);
assert.strictEqual((head.match(/<link rel="canonical" href="https:\/\/openvibe.community\/p\/x">/g) || []).length, 1);
assert.ok(head.includes('<meta property="og:type" content="article">'));
assert.ok(head.includes('<meta property="og:type" content="article">'));
- assert.ok(head.includes('<meta name="twitter:card" content="summary">'), 'no large card without a page image');
+ assert.ok(head.includes('<meta name="twitter:card" content="summary_large_image">'), 'the content-hashed site card is large');
+ assert.match(head, /<meta property="og:image" content="https:\/\/openvibe\.community\/shared\/social\/community\.jpg\?v=[0-9a-f]{12}">/);
+ assert.ok(head.includes('<meta property="og:image:width" content="1200">') && head.includes('<meta property="og:image:height" content="630">'));
assert.ok(head.includes('<meta name="ai-summary" content="A paste.">'));
assert.ok(head.includes('<meta name="ai-summary" content="A paste.">'));
assert.ok(head.includes('<meta property="article:published_time" content="2026-09-15T10:00:00.000Z">'));
assert.ok(head.includes('<meta property="article:published_time" content="2026-09-15T10:00:00.000Z">'));
assert.ok(head.includes('"name":"\\u003c/script>\\u003cb>"') && !head.includes('</script><b>'));
assert.ok(head.includes('"name":"\\u003c/script>\\u003cb>"') && !head.includes('</script><b>'));
@@ -139,6 +139,22 @@ const { boot, check, done } = require('./helpers/app');
@@ -139,6 +139,22 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((head.match(/<link rel="canonical"/g) || []).length, 1, 'one canonical');
assert.strictEqual((head.match(/<link rel="canonical"/g) || []).length, 1, 'one canonical');
});
});
+ await check('the home page shares the site card, content-hashed, with its 1200×630 size and the Twitter mirror', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ const head = r.text.split('</head>')[0];
+ const image = /<meta property="og:image" content="([^"]+)">/.exec(head)?.[1] || '';
+ assert.match(image, /^https:\/\/openvibe\.community\/shared\/social\/community\.jpg\?v=[0-9a-f]{12}$/);
+ assert.ok(head.includes('<meta property="og:image:width" content="1200">'));
+ assert.ok(head.includes('<meta property="og:image:height" content="630">'));
+ assert.ok(head.includes('<meta name="twitter:card" content="summary_large_image">'));
+ assert.ok(head.includes(`<meta name="twitter:image" content="${image}">`));
+ const path = new URL(image).pathname + new URL(image).search;
+ const card = await t.get(path);
+ assert.strictEqual(card.status, 200, path);
+ assert.ok(card.headers.get('content-type').startsWith('image/jpeg'));
+ });
+
await check('HTML pages carry the private page policy; raw and download never reach a shared cache', async () => {
await check('HTML pages carry the private page policy; raw and download never reach a shared cache', async () => {
for (const path of ['/', '/pastes', '/p/amber-fox-42', '/updates', '/nope-not-a-page']) {
for (const path of ['/', '/pastes', '/p/amber-fox-42', '/updates', '/nope-not-a-page']) {
const r = await t.get(path);
const r = await t.get(path);
@@ -26,7 +26,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -26,7 +26,7 @@ const { boot, check, done } = require('./helpers/app');
has(r.text, '<title>OpenVibe.Community: pastes, forums and the OpenVibe roadmap</title>');
has(r.text, '<title>OpenVibe.Community: pastes, forums and the OpenVibe roadmap</title>');
has(r.text, '<link rel="canonical" href="https://openvibe.community/">');
has(r.text, '<link rel="canonical" href="https://openvibe.community/">');
has(r.text, '<meta property="og:type" content="website">');
has(r.text, '<meta property="og:type" content="website">');
- has(r.text, '<meta name="twitter:card" content="summary">');
+ has(r.text, '<meta name="twitter:card" content="summary_large_image">');
has(r.text, '"@type":"WebSite"');
has(r.text, '"@type":"WebSite"');
has(r.text, '/shared/theme-loader.js?v=');
has(r.text, '/shared/theme-loader.js?v=');
has(r.text, '/shared/navbar.js?v=');
has(r.text, '/shared/navbar.js?v=');