openvibers/openrestream Commits

OpenRestream: the front page's head carries og, twitter and JSON-LD (share card included) (#57)

OP OpenVibers committed 11 Oct 2026 · cd511c0419 · 3 files, +72 −8

openre.stream's head is now built with `openvibe-shared/seo`'s `headTags`, so it gains `og:*`, `twitter:*` and JSON-LD: the SEO survey found the front page with none of them.

- Public, indexable pages share the site's card (`social.url('openre')`, served by `/shared/`). The front page adds a `WebApplication` JSON-LD.
- Signed-in owner pages stay `noindex` with no image and no JSON-LD.
- No manifest is linked: the shared manifest's icon files are not served here, and a manifest pointing at missing icons would be worse than none.

Test: `discovery.test.js` checks `og:title`, an absolute `og:image`, `summary_large_image`, the JSON-LD, the private-page case, and that the card URL answers `image/jpeg`. `destinations-ui` still passes. Drafted by a DeepSeek agent; reviewed, and the manifest part removed.

Changes

ddiff --git a/server/ui/layout.js b/server/ui/layout.js
iindex 45fa480..ec0440e 100644
---- a/server/ui/layout.js
++++ b/server/ui/layout.js
@@@ -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>
ddiff --git a/server/ui/routes.js b/server/ui/routes.js
iindex 4743b6a..1bdeaef 100644
---- a/server/ui/routes.js
++++ b/server/ui/routes.js
@@@ -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' })}`,
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
iindex 8ad291b..e8eb439 100644
---- a/test/discovery.test.js
++++ b/test/discovery.test.js
@@@ -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-live
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.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();