openvibers/openrestream Commits
OpenRestream: the front page's head carries og, twitter and JSON-LD (share card included) (#57)
Changes
@@@ -1,9 +1,16 @@'use strict';const ovServe = require('openvibe-shared/serve');++const seo = require('openvibe-shared/seo');++const social = require('openvibe-shared/social');/*** Page shell for openre.stream: server-rendered HTML that works without JavaScript, with the shared* OpenVibe Frame (theme loader, navbar and footer from openvibe.network, footer SSR from the* pinned openvibe-shared release) the way OpenVibe.Community renders it.++ *++ * The <head>'s SEO tags (title, description, canonical, robots, og:*, twitter:* and JSON-LD) come++ * from openvibe-shared/seo, so every OpenVibe site emits the same, consistently clipped fields. The++ * public, indexable pages share the site's card (openvibe-shared/social) and may carry JSON-LD; the++ * signed-in owner pages (/streams, /sessions, …) stay noindex and get neither an image nor JSON-LD.*/const NETWORK_URL = 'https://openvibe.network';const SITE = 'OpenRestream';@@@ -36,8 +43,21 @@ main.ore{max-width:1040px;margin:0 auto;padding:24px 16px 64px}const DESCRIPTION = 'OpenRestream: open restreaming. Go live once from OBS and stream to Twitch, YouTube, Kick and any RTMP or SRT server at the same time, with OpenVibe Live on by default. Free and open source.';--function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,nofollow', config, description = DESCRIPTION, styles = [] }) {++function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,nofollow', config, description = DESCRIPTION, styles = [], jsonLd = null }) {const pageTitle = title ? `${title} · ${SITE}` : `${SITE} — go live once, stream everywhere`;++ const canonical = config.baseUrl + canonicalPath;++ // Indexable (public) pages are the only ones a crawler shares: they carry the site's card and,++ // where the route supplies one, JSON-LD. The signed-in owner pages stay noindex with neither.++ const indexable = /^\s*index/i.test(String(robots));++ const seoHead = seo.headTags({++ title: pageTitle,++ description,++ canonical,++ siteName: SITE,++ robots,++ image: indexable ? social.url('openre') : null,++ jsonLd: indexable ? jsonLd : null,++ });const nav = {service: 'openre',apiBase: NETWORK_URL,@@@ -61,10 +81,7 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">--<title>${esc(pageTitle)}</title>--<meta name="description" content="${esc(description)}">--<meta name="robots" content="${esc(robots)}">--<link rel="canonical" href="${esc(config.baseUrl + canonicalPath)}">++${seoHead}${icon}<script src="${ovServe.url('theme-loader.js')}" defer></script><style>${CSS}</style>@@@ -15,6 +15,7 @@*/const crypto = require('crypto');const ovServe = require('openvibe-shared/serve');++const seo = require('openvibe-shared/seo');const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');const express = require('express');@@@ -28,6 +29,10 @@ const { REASONS: LIVE_REASONS } = require('../live-link');const httpUrl = (u) => (/^https?:\/\/[^\s"'<>]+$/.test(String(u || '')) ? String(u) : null);const pill = (state) => `<span class="pill ${esc(state)}">${esc(state)}</span>`;++// The front page's description reaches the <meta>, og:*/twitter:* and the softwareApp JSON-LD from++// one place (server/ui/layout.js clips it to 160 for the meta tags).++const FRONT_PAGE_DESCRIPTION = 'OpenRestream: open restreaming. Go live once from OBS and stream to Twitch, YouTube, Kick and any RTMP or SRT server at the same time, with OpenVibe Live on by default. Free, open source, no follower minimum.';++function csrfFor(token) { return crypto.createHash('sha256').update(`openre-csrf:${token}`).digest('hex').slice(0, 32); }function createUiRouter({ rt, auth, limits = null, liveLink = null }) {@@@ -94,7 +99,11 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {const mine = signedIn ? await store.definitions.list({ owner_subject: req.caller.subject }) : [];page(req, res, {canonicalPath: '/', robots: 'index,follow', styles: [showcase.STYLESHEET],-- description: 'OpenRestream: open restreaming. Go live once from OBS and stream to Twitch, YouTube, Kick and any RTMP or SRT server at the same time, with OpenVibe Live on by default. Free, open source, no follower minimum.',++ description: FRONT_PAGE_DESCRIPTION,++ // Structured data for the front page only: the site is a web application (openre.stream).++ jsonLd: [seo.jsonLd.softwareApp({++ name: 'OpenRestream', url: `${config.baseUrl}/`, description: FRONT_PAGE_DESCRIPTION, category: 'MultimediaApplication',++ })],body: `${frontPage(config)}${signedIn ? `<h2>Your streams</h2>${mine.length ? await streamTable(mine) : '<p class="muted">No streams yet.</p>'}<p><a href="/streams">Manage streams</a></p>` : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' })}`,@@@ -4,7 +4,8 @@// OpenRestream does today. All three come from openvibe-shared/seo, the shared toolkit.const assert = require('assert');const seo = require('openvibe-shared/seo');--const { bootApi, request, suite } = require('./helpers');++const social = require('openvibe-shared/social');++const { bootApi, request, suite, userToken, OWNER } = require('./helpers');const t = suite('discovery');@@@ -54,7 +55,7 @@ t('llms.txt is plain text, leads with open restreaming (OpenVibe Live on by defa});t('the front page leads with open restreaming, links to Live for browser go-live, and says what runs here', async () => {-- const api = await bootApi({});++ const api = await bootApi({ env: { BASE_URL: 'https://openre.stream' } });try {const r = await request(api.base, 'GET', '/');assert.strictEqual(r.status, 200);@@@ -66,9 +67,46 @@ t('the front page leads with open restreaming, links to Live for browser go-liveassert.match(r.text, /Going live from a browser happens on OpenVibe\.Live for now/);assert.match(r.text, /href="\/shared\/showcase\.css\?v=[0-9a-f]{12}"/);assert.strictEqual((r.text.match(/<h1[ >]/g) || []).length, 1, 'one h1');++ // The SEO head (openvibe-shared/seo): the front page is shareable and machine-readable.++ assert.match(r.text, /<meta property="og:title" content="OpenRestream — go live once, stream everywhere">/);++ assert.match(r.text, /<meta name="twitter:card" content="summary_large_image">/);++ assert.match(r.text, /<meta property="og:image" content="https:\/\/openre\.stream\/shared\/social\/openre\.jpg\?v=[0-9a-f]{12}">/);++ const ld = /<script type="application\/ld\+json">(.*?)<\/script>/s.exec(r.text);++ assert.ok(ld, 'a JSON-LD script is present');++ const app = JSON.parse(ld[1]);++ assert.strictEqual(app['@type'], 'WebApplication');++ assert.strictEqual(app.name, 'OpenRestream');++ assert.strictEqual(app.applicationCategory, 'MultimediaApplication');++ } finally {++ await api.close();++ }++});++++t('the signed-in owner pages stay noindex and carry no og:image or JSON-LD', async () => {++ const api = await bootApi({});++ try {++ const r = await request(api.base, 'GET', '/streams', { cookie: `ov_token=${userToken({ subjectId: OWNER })}` });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /<meta name="robots" content="noindex,nofollow">/);++ assert.ok(!r.text.includes('og:image'), 'no share image on a private page');++ assert.ok(!r.text.includes('application/ld+json'), 'no JSON-LD on a private page');} finally {await api.close();}});++t('the site serves its own pinned share card at /shared/social/openre.jpg', async () => {++ const api = await bootApi({});++ try {++ const card = social.url('openre');++ assert.ok(card && card.startsWith('/shared/social/openre.jpg?v='), 'the card has a content-addressed URL');++ const r = await request(api.base, 'GET', card);++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^image\/jpeg/);++ } finally {++ await api.close();++ }++});++++t.run();
@@ -1,9 +1,16 @@
@@ -1,9 +1,16 @@
'use strict';
'use strict';
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
+const seo = require('openvibe-shared/seo');
+const social = require('openvibe-shared/social');
/**
/**
* Page shell for openre.stream: server-rendered HTML that works without JavaScript, with the shared
* Page shell for openre.stream: server-rendered HTML that works without JavaScript, with the shared
* OpenVibe Frame (theme loader, navbar and footer from openvibe.network, footer SSR from the
* OpenVibe Frame (theme loader, navbar and footer from openvibe.network, footer SSR from the
* pinned openvibe-shared release) the way OpenVibe.Community renders it.
* pinned openvibe-shared release) the way OpenVibe.Community renders it.
+ *
+ * The <head>'s SEO tags (title, description, canonical, robots, og:*, twitter:* and JSON-LD) come
+ * from openvibe-shared/seo, so every OpenVibe site emits the same, consistently clipped fields. The
+ * public, indexable pages share the site's card (openvibe-shared/social) and may carry JSON-LD; the
+ * signed-in owner pages (/streams, /sessions, …) stay noindex and get neither an image nor JSON-LD.
*/
*/
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SITE = 'OpenRestream';
const SITE = 'OpenRestream';
@@ -36,8 +43,21 @@ main.ore{max-width:1040px;margin:0 auto;padding:24px 16px 64px}
@@ -36,8 +43,21 @@ main.ore{max-width:1040px;margin:0 auto;padding:24px 16px 64px}
const DESCRIPTION = 'OpenRestream: open restreaming. Go live once from OBS and stream to Twitch, YouTube, Kick and any RTMP or SRT server at the same time, with OpenVibe Live on by default. Free and open source.';
const DESCRIPTION = 'OpenRestream: open restreaming. Go live once from OBS and stream to Twitch, YouTube, Kick and any RTMP or SRT server at the same time, with OpenVibe Live on by default. Free and open source.';
-function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,nofollow', config, description = DESCRIPTION, styles = [] }) {
+function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,nofollow', config, description = DESCRIPTION, styles = [], jsonLd = null }) {
const pageTitle = title ? `${title} · ${SITE}` : `${SITE} — go live once, stream everywhere`;
const pageTitle = title ? `${title} · ${SITE}` : `${SITE} — go live once, stream everywhere`;
+ const canonical = config.baseUrl + canonicalPath;
+ // Indexable (public) pages are the only ones a crawler shares: they carry the site's card and,
+ // where the route supplies one, JSON-LD. The signed-in owner pages stay noindex with neither.
+ const indexable = /^\s*index/i.test(String(robots));
+ const seoHead = seo.headTags({
+ title: pageTitle,
+ description,
+ canonical,
+ siteName: SITE,
+ robots,
+ image: indexable ? social.url('openre') : null,
+ jsonLd: indexable ? jsonLd : null,
+ });
const nav = {
const nav = {
service: 'openre',
service: 'openre',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
@@ -61,10 +81,7 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
@@ -61,10 +81,7 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
<head>
<head>
<meta charset="utf-8">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>${esc(pageTitle)}</title>
-<meta name="description" content="${esc(description)}">
-<meta name="robots" content="${esc(robots)}">
-<link rel="canonical" href="${esc(config.baseUrl + canonicalPath)}">
+${seoHead}
${icon}
${icon}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<style>${CSS}</style>
<style>${CSS}</style>
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
+const seo = 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');
const express = require('express');
const express = require('express');
@@ -28,6 +29,10 @@ const { REASONS: LIVE_REASONS } = require('../live-link');
@@ -28,6 +29,10 @@ const { REASONS: LIVE_REASONS } = require('../live-link');
const httpUrl = (u) => (/^https?:\/\/[^\s"'<>]+$/.test(String(u || '')) ? String(u) : null);
const httpUrl = (u) => (/^https?:\/\/[^\s"'<>]+$/.test(String(u || '')) ? String(u) : null);
const pill = (state) => `<span class="pill ${esc(state)}">${esc(state)}</span>`;
const pill = (state) => `<span class="pill ${esc(state)}">${esc(state)}</span>`;
+// The front page's description reaches the <meta>, og:*/twitter:* and the softwareApp JSON-LD from
+// one place (server/ui/layout.js clips it to 160 for the meta tags).
+const FRONT_PAGE_DESCRIPTION = 'OpenRestream: open restreaming. Go live once from OBS and stream to Twitch, YouTube, Kick and any RTMP or SRT server at the same time, with OpenVibe Live on by default. Free, open source, no follower minimum.';
+
function csrfFor(token) { return crypto.createHash('sha256').update(`openre-csrf:${token}`).digest('hex').slice(0, 32); }
function csrfFor(token) { return crypto.createHash('sha256').update(`openre-csrf:${token}`).digest('hex').slice(0, 32); }
function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
@@ -94,7 +99,11 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
@@ -94,7 +99,11 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
const mine = signedIn ? await store.definitions.list({ owner_subject: req.caller.subject }) : [];
const mine = signedIn ? await store.definitions.list({ owner_subject: req.caller.subject }) : [];
page(req, res, {
page(req, res, {
canonicalPath: '/', robots: 'index,follow', styles: [showcase.STYLESHEET],
canonicalPath: '/', robots: 'index,follow', styles: [showcase.STYLESHEET],
- description: 'OpenRestream: open restreaming. Go live once from OBS and stream to Twitch, YouTube, Kick and any RTMP or SRT server at the same time, with OpenVibe Live on by default. Free, open source, no follower minimum.',
+ description: FRONT_PAGE_DESCRIPTION,
+ // Structured data for the front page only: the site is a web application (openre.stream).
+ jsonLd: [seo.jsonLd.softwareApp({
+ name: 'OpenRestream', url: `${config.baseUrl}/`, description: FRONT_PAGE_DESCRIPTION, category: 'MultimediaApplication',
+ })],
body: `${frontPage(config)}
body: `${frontPage(config)}
${signedIn ? `<h2>Your streams</h2>${mine.length ? await streamTable(mine) : '<p class="muted">No streams yet.</p>'}<p><a href="/streams">Manage streams</a></p>` : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}
${signedIn ? `<h2>Your streams</h2>${mine.length ? await streamTable(mine) : '<p class="muted">No streams yet.</p>'}<p><a href="/streams">Manage streams</a></p>` : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}
${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' })}`,
${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' })}`,
@@ -4,7 +4,8 @@
@@ -4,7 +4,8 @@
// OpenRestream does today. All three come from openvibe-shared/seo, the shared toolkit.
// OpenRestream does today. All three come from openvibe-shared/seo, the shared toolkit.
const assert = require('assert');
const assert = require('assert');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
-const { bootApi, request, suite } = require('./helpers');
+const social = require('openvibe-shared/social');
+const { bootApi, request, suite, userToken, OWNER } = require('./helpers');
const t = suite('discovery');
const t = suite('discovery');
@@ -54,7 +55,7 @@ t('llms.txt is plain text, leads with open restreaming (OpenVibe Live on by defa
@@ -54,7 +55,7 @@ t('llms.txt is plain text, leads with open restreaming (OpenVibe Live on by defa
});
});
t('the front page leads with open restreaming, links to Live for browser go-live, and says what runs here', async () => {
t('the front page leads with open restreaming, links to Live for browser go-live, and says what runs here', async () => {
- const api = await bootApi({});
+ const api = await bootApi({ env: { BASE_URL: 'https://openre.stream' } });
try {
try {
const r = await request(api.base, 'GET', '/');
const r = await request(api.base, 'GET', '/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
@@ -66,9 +67,46 @@ t('the front page leads with open restreaming, links to Live for browser go-live
@@ -66,9 +67,46 @@ t('the front page leads with open restreaming, links to Live for browser go-live
assert.match(r.text, /Going live from a browser happens on OpenVibe\.Live for now/);
assert.match(r.text, /Going live from a browser happens on OpenVibe\.Live for now/);
assert.match(r.text, /href="\/shared\/showcase\.css\?v=[0-9a-f]{12}"/);
assert.match(r.text, /href="\/shared\/showcase\.css\?v=[0-9a-f]{12}"/);
assert.strictEqual((r.text.match(/<h1[ >]/g) || []).length, 1, 'one h1');
assert.strictEqual((r.text.match(/<h1[ >]/g) || []).length, 1, 'one h1');
+ // The SEO head (openvibe-shared/seo): the front page is shareable and machine-readable.
+ assert.match(r.text, /<meta property="og:title" content="OpenRestream — go live once, stream everywhere">/);
+ assert.match(r.text, /<meta name="twitter:card" content="summary_large_image">/);
+ assert.match(r.text, /<meta property="og:image" content="https:\/\/openre\.stream\/shared\/social\/openre\.jpg\?v=[0-9a-f]{12}">/);
+ const ld = /<script type="application\/ld\+json">(.*?)<\/script>/s.exec(r.text);
+ assert.ok(ld, 'a JSON-LD script is present');
+ const app = JSON.parse(ld[1]);
+ assert.strictEqual(app['@type'], 'WebApplication');
+ assert.strictEqual(app.name, 'OpenRestream');
+ assert.strictEqual(app.applicationCategory, 'MultimediaApplication');
+ } finally {
+ await api.close();
+ }
+});
+
+t('the signed-in owner pages stay noindex and carry no og:image or JSON-LD', async () => {
+ const api = await bootApi({});
+ try {
+ const r = await request(api.base, 'GET', '/streams', { cookie: `ov_token=${userToken({ subjectId: OWNER })}` });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /<meta name="robots" content="noindex,nofollow">/);
+ assert.ok(!r.text.includes('og:image'), 'no share image on a private page');
+ assert.ok(!r.text.includes('application/ld+json'), 'no JSON-LD on a private page');
} finally {
} finally {
await api.close();
await api.close();
}
}
});
});
+t('the site serves its own pinned share card at /shared/social/openre.jpg', async () => {
+ const api = await bootApi({});
+ try {
+ const card = social.url('openre');
+ assert.ok(card && card.startsWith('/shared/social/openre.jpg?v='), 'the card has a content-addressed URL');
+ const r = await request(api.base, 'GET', card);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^image\/jpeg/);
+ } finally {
+ await api.close();
+ }
+});
+
+
t.run();
t.run();