openvibers/openvibe.space Commits
Share the content-hashed Space card (#202)
Changes
@@@ -13,6 +13,7 @@ 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');/** Escape a value for HTML text or a quoted attribute. */function escapeHtml(s) {@@@ -22,7 +23,8 @@ function escapeHtml(s) {const SITE_NAME = 'OpenVibe.Space';const NETWORK_URL = 'https://openvibe.network';const DEFAULT_DESCRIPTION = 'OpenVibe.Space: apps on your OpenVibe account, built with OpenVibe AI or from a template, and static hosting with immutable deploys and rollback.';--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('space')}`;// 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).@@@ -118,6 +120,7 @@ function renderPage(o) {const nav = navbarInit(o);const foot = footerInit(o);const feeds = o.feeds || [];++ 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) {siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',agentLayer: o.agentLayer,llms: o.llms,-- 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,summary: description, url: canonical, updated: o.modified,navbar: nav, home: '/', navLinks: nav.links.map(({ label, href }) => ({ label, href })),@@@ -137,6 +140,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: 'space', 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)}">`),`<link rel="stylesheet" href="${asset('css/space.css')}">`,@@@ -160,8 +165,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;}module.exports = { renderPage, asset, assetVersion, abs, setRelease, escapeHtml, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };@@@ -37,6 +37,22 @@ const { boot, check, done } = require('./helpers/app');assert.doesNotMatch(r.text, /href="\/s"/, 'nothing links a /s that only redirects');});++ 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\.space\/shared\/social\/space\.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('/updates is this site\'s own update log, and the footer links it', async () => {const r = await t.get('/updates');assert.strictEqual(r.status, 200);
@@ -13,6 +13,7 @@ const appIcon = require('openvibe-shared/app-icon');
@@ -13,6 +13,7 @@ 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');
/** Escape a value for HTML text or a quoted attribute. */
/** Escape a value for HTML text or a quoted attribute. */
function escapeHtml(s) {
function escapeHtml(s) {
@@ -22,7 +23,8 @@ function escapeHtml(s) {
@@ -22,7 +23,8 @@ function escapeHtml(s) {
const SITE_NAME = 'OpenVibe.Space';
const SITE_NAME = 'OpenVibe.Space';
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const DEFAULT_DESCRIPTION = 'OpenVibe.Space: apps on your OpenVibe account, built with OpenVibe AI or from a template, and static hosting with immutable deploys and rollback.';
const DEFAULT_DESCRIPTION = 'OpenVibe.Space: apps on your OpenVibe account, built with OpenVibe AI or from a template, and static hosting with immutable deploys and rollback.';
-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('space')}`;
// 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).
@@ -118,6 +120,7 @@ function renderPage(o) {
@@ -118,6 +120,7 @@ function renderPage(o) {
const nav = navbarInit(o);
const nav = navbarInit(o);
const foot = footerInit(o);
const foot = footerInit(o);
const feeds = o.feeds || [];
const feeds = o.feeds || [];
+ 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) {
siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',
siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',
agentLayer: o.agentLayer,
agentLayer: o.agentLayer,
llms: o.llms,
llms: o.llms,
- 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,
summary: description, url: canonical, updated: o.modified,
summary: description, url: canonical, updated: o.modified,
navbar: nav, home: '/', navLinks: nav.links.map(({ label, href }) => ({ label, href })),
navbar: nav, home: '/', navLinks: nav.links.map(({ label, href }) => ({ label, href })),
@@ -137,6 +140,8 @@ function renderPage(o) {
@@ -137,6 +140,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: 'space', iconBase: '/assets' }),
appIcon.headTags({ site: 'space', 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)}">`),
`<link rel="stylesheet" href="${asset('css/space.css')}">`,
`<link rel="stylesheet" href="${asset('css/space.css')}">`,
@@ -160,8 +165,7 @@ document.addEventListener('DOMContentLoaded', function () {
@@ -160,8 +165,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;
}
}
module.exports = { renderPage, asset, assetVersion, abs, setRelease, escapeHtml, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };
module.exports = { renderPage, asset, assetVersion, abs, setRelease, escapeHtml, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };
@@ -37,6 +37,22 @@ const { boot, check, done } = require('./helpers/app');
@@ -37,6 +37,22 @@ const { boot, check, done } = require('./helpers/app');
assert.doesNotMatch(r.text, /href="\/s"/, 'nothing links a /s that only redirects');
assert.doesNotMatch(r.text, /href="\/s"/, 'nothing links a /s that only redirects');
});
});
+ 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\.space\/shared\/social\/space\.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('/updates is this site\'s own update log, and the footer links it', async () => {
await check('/updates is this site\'s own update log, and the footer links it', async () => {
const r = await t.get('/updates');
const r = await t.get('/updates');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);