openvibers/openvibe.live Commits

Docs served on-site at /docs/<name>; WHIP ingest host → ingest.openvibe.live

OP OpenVibers committed 29 Aug 2026 · ddb287acc5 · 9 files, +429 −17

Nothing served docs/*.md before, so https://openvibe.live/docs/whip.md#… was a 404 and
the only shareable link was GitHub. server/docs/routes.js now renders the docs folder
at /docs/<name> (and /docs/<name>.md) with a dependency-free renderer covering exactly
the Markdown the docs use, GitHub-compatible heading slugs so anchors round-trip, and
relative links rewritten to on-site or GitHub targets. Docs pages are listed in the
sitemap. test/docs-render.test.js renders every doc and guards the public anchors.

The dedicated WHIP host is now ingest.openvibe.live: nginx server_name added (the
wildcard cert already covers it; whip.openvibe.live keeps working), and docs, README,
the publisher page and code comments reference it. The URL registry on openvibe.network
was updated (WHIP_PUBLIC_URL=https://ingest.openvibe.live, WHIP_PUBLIC_URL_ENABLED=true).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex c0eac89a..2a75db4c 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -63,6 +63,8 @@ Standalone Node scripts in `test/` using `assert`. They create temp SQLite datab
## Documentation
++Every file below is also served on the site at `/docs/<name>` (rendered by `server/docs/routes.js`, GitHub-compatible heading anchors) — link to `https://openvibe.live/docs/whip#…` rather than to GitHub when pointing users at a doc.
++
- [docs/architecture.md](docs/architecture.md) — System design, data flows, module map
- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)
- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)
ddiff --git a/README.md b/README.md
iindex 9b7f4770..f024bc70 100644
---- a/README.md
++++ b/README.md
@@@ -35,7 +35,7 @@ This repository contains the OpenVibe.Live server, browser assets, and runtime c
- RTMP ingest using `node-media-server` (ports 1935 / 9935, public host `ingest.openvibe.live`).
- optional WebRTC SFU via `mediasoup` (ports 11000-11300 in production).
- JSMPEG relay (TLS relay ports 9710-9789 via nginx stream).
--- WHIP/HTTP ingestion support (`whip.openvibe.live`) — open CORS, so even a backend-less web page can publish; see [docs/whip.md → Publishing from a browser](docs/whip.md#publishing-from-a-browser) and the hosted [browser publisher](https://openvibe.live/whip-publisher.html).
++- WHIP/HTTP ingestion support (`ingest.openvibe.live`) — open CORS, so even a backend-less web page can publish; see [docs/whip.md → Publishing from a browser](docs/whip.md#publishing-from-a-browser) and the hosted [browser publisher](https://openvibe.live/whip-publisher.html).
- real-time broadcast and control channels.
### Media (VODs / clips / pastes / thumbnails)
@@@ -118,7 +118,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
## Deployment
- Production path `/opt/openvibe.live`, env file `/etc/openvibe/live.env` (0600), unit `openvibe-live.service` (see `deploy/systemd/`).
--- nginx reference config at `deploy/nginx/openvibe.live.conf` (`openvibe.live`, `www.openvibe.live`, `whip.openvibe.live`; certs `/etc/letsencrypt/live/openvibe.live/`).
++- nginx reference config at `deploy/nginx/openvibe.live.conf` (`openvibe.live`, `www.openvibe.live`, `ingest.openvibe.live`; certs `/etc/letsencrypt/live/openvibe.live/`).
---
ddiff --git a/deploy/nginx/openvibe.live.conf b/deploy/nginx/openvibe.live.conf
iindex 10760984..996dcd70 100644
---- a/deploy/nginx/openvibe.live.conf
++++ b/deploy/nginx/openvibe.live.conf
@@@ -6,7 +6,7 @@
##
## There is also a generator (openvibe.network server/deploy/nginx-generator.js, driven
## from the admin panel's Deploy → Nginx). Its entry for this site has drifted: it knows
--## nothing about whip.openvibe.live or the connection limits below, so regenerating from
++## nothing about whip.openvibe.live / ingest.openvibe.live or the connection limits below, so regenerating from
## it would silently drop them. Update the generator too before using that path.
## ═══════════════════════════════════════════════════════════════
@@@ -22,7 +22,7 @@ limit_conn_zone $binary_remote_addr zone=asset_limit:10m;
server {
listen 80;
listen [::]:80;
-- server_name openvibe.live www.openvibe.live whip.openvibe.live;
++ server_name openvibe.live www.openvibe.live whip.openvibe.live ingest.openvibe.live;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
@@@ -31,7 +31,7 @@ server {
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
-- server_name openvibe.live www.openvibe.live whip.openvibe.live;
++ server_name openvibe.live www.openvibe.live whip.openvibe.live ingest.openvibe.live;
ssl_certificate /etc/letsencrypt/live/openvibe.live/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.live/privkey.pem;
ddiff --git a/docs/whip.md b/docs/whip.md
iindex e65ed326..2bb393c9 100644
---- a/docs/whip.md
++++ b/docs/whip.md
@@@ -7,7 +7,7 @@ OpenVibe.Live accepts live video over **WHIP** (WebRTC-HTTP Ingestion Protocol,
## Endpoint
```
--POST https://whip.openvibe.live/whip/{streamKey}
++POST https://ingest.openvibe.live/whip/{streamKey}
Content-Type: application/sdp
```
@@@ -76,7 +76,7 @@ Access-Control-Expose-Headers: Location, X-WHIP-ERROR
There are no cookies involved, so `credentials` is never needed — call `fetch()` normally. You can verify from a shell:
```bash
--curl -si -X OPTIONS https://whip.openvibe.live/whip/x \
++curl -si -X OPTIONS https://ingest.openvibe.live/whip/x \
-H 'Origin: https://my-static-site.example' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type'
@@@ -85,14 +85,14 @@ curl -si -X OPTIONS https://whip.openvibe.live/whip/x \
## Publishing from a browser
--> **Direct link to this section:** <https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md#publishing-from-a-browser>
++> **Direct link to this section:** <https://openvibe.live/docs/whip#publishing-from-a-browser> (also on [GitHub](https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md#publishing-from-a-browser))
> Everything needed to build a browser-only publisher is in this section — copy it whole into your AI assistant or your notes.
**Spec in five lines (paste-ready):**
```text
OpenVibe.Live WHIP ingest — browser publisher requirements
--1. POST the SDP offer to https://whip.openvibe.live/whip/<streamKey> with Content-Type: application/sdp. No cookies, no other auth; CORS is open (*).
++1. POST the SDP offer to https://ingest.openvibe.live/whip/<streamKey> with Content-Type: application/sdp. No cookies, no other auth; CORS is open (*).
2. Expect 201; body = SDP answer (application/sdp); Location header = session resource URL (read it — it is exposed via CORS).
3. Offer: one audio + one video m-section, a=setup:actpass, BUNDLE + rtcp-mux, single encoding (no simulcast). Codecs: VP8 or H.264 (42e01f, packetization-mode=1) + Opus 48k. AV1/VP9/HEVC are ignored.
4. No STUN/TURN needed (server is ICE-lite); do not wait for ICE gathering; PATCH/trickle is optional.
@@@ -105,7 +105,7 @@ A complete publisher is about forty lines. This is the core of [`whip-publisher.
<video id="preview" autoplay muted playsinline></video>
<button id="go">Go live</button>
<script>
--const WHIP_URL = 'https://whip.openvibe.live/whip/<your-stream-key>';
++const WHIP_URL = 'https://ingest.openvibe.live/whip/<your-stream-key>';
let pc, resourceUrl;
async function goLive() {
@@@ -161,7 +161,7 @@ Things worth knowing:
## Other clients
- **OBS Studio (30+):** *Settings → Stream → Service: WHIP*, Server = the WHIP URL, Bearer Token = your stream key.
--- **FFmpeg (7.0+):** `ffmpeg -re -i input.mp4 -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p -c:a libopus -f whip "https://whip.openvibe.live/whip/<streamKey>"`
--- **GStreamer:** `... ! whipsink whip-endpoint="https://whip.openvibe.live/whip/<streamKey>"` (needs `gst-plugins-rs`).
++- **FFmpeg (7.0+):** `ffmpeg -re -i input.mp4 -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p -c:a libopus -f whip "https://ingest.openvibe.live/whip/<streamKey>"`
++- **GStreamer:** `... ! whipsink whip-endpoint="https://ingest.openvibe.live/whip/<streamKey>"` (needs `gst-plugins-rs`).
The Broadcast page shows ready-to-paste commands for each with your key filled in. See [Broadcasting Guide](broadcasting.md) for the other ingest methods.
ddiff --git a/public/whip-publisher.html b/public/whip-publisher.html
iindex cf3430d8..88c5574d 100644
---- a/public/whip-publisher.html
++++ b/public/whip-publisher.html
@@@ -14,7 +14,7 @@
into your own site.
Protocol walkthrough + API reference:
-- https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md
++ https://openvibe.live/docs/whip#publishing-from-a-browser
Programmatic use: window.OpenVibeWhip.start({ url, token, source, maxBitrate })
window.OpenVibeWhip.stop()
@@@ -68,7 +68,7 @@
<div class="card">
<label for="whip-url">WHIP URL</label>
-- <input id="whip-url" type="url" placeholder="https://whip.openvibe.live/whip/&lt;your-stream-key&gt;" autocomplete="off" spellcheck="false">
++ <input id="whip-url" type="url" placeholder="https://ingest.openvibe.live/whip/&lt;your-stream-key&gt;" autocomplete="off" spellcheck="false">
<p class="hint">Copy it from <strong>Broadcast → your stream → Streaming method: WHIP</strong>. The key inside the URL is your credential — treat the URL like a password. It is remembered in this browser only.</p>
<label for="whip-token">Bearer token <span style="text-transform:none;letter-spacing:0">(optional — only needed if your URL uses the numeric slot form <code>/whip/42</code>)</span></label>
@@@ -114,7 +114,7 @@
<pre id="whip-log" class="log">Ready.</pre>
</div>
-- <p class="hint">How it works: the page calls <code>getUserMedia</code>, builds a WebRTC offer, <code>POST</code>s it as <code>application/sdp</code> to the WHIP URL, applies the SDP answer, and <code>DELETE</code>s the returned resource when you stop. Full protocol notes in <a href="https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md">docs/whip.md</a>. View-source this page to copy it.</p>
++ <p class="hint">How it works: the page calls <code>getUserMedia</code>, builds a WebRTC offer, <code>POST</code>s it as <code>application/sdp</code> to the WHIP URL, applies the SDP answer, and <code>DELETE</code>s the returned resource when you stop. Full protocol notes in <a href="https://openvibe.live/docs/whip#publishing-from-a-browser">openvibe.live/docs/whip</a>. View-source this page to copy it.</p>
</main>
<script>
@@@ -201,7 +201,7 @@
const maxBitrate = Number(opts.maxBitrate) || Number(kbps) * 1000;
if (!/^https?:\/\/[^/]+\/whip\/[^/?#]+\/?$/.test(url)) {
-- throw new Error('WHIP URL should look like https://whip.openvibe.live/whip/<stream-key>');
++ throw new Error('WHIP URL should look like https://ingest.openvibe.live/whip/<stream-key>');
}
try { localStorage.setItem(STORAGE_KEY, url); } catch { /* private mode */ }
ddiff --git a/server/docs/routes.js b/server/docs/routes.js
nnew file mode 100644
iindex 00000000..a047d0f5
---- /dev/null
++++ b/server/docs/routes.js
@@@ -0,0 +1,310 @@
++/**
++ * OpenVibe.Live — Docs pages
++ *
++ * Serves the Markdown files in ./docs as HTML at /docs/<name> (and /docs/<name>.md), so a
++ * link such as https://openvibe.live/docs/whip#publishing-from-a-browser works without
++ * sending people to GitHub. Heading ids follow GitHub's slug rules, so anchors copied from
++ * GitHub keep working here and vice versa.
++ *
++ * The renderer covers exactly the Markdown subset the docs use — headings, paragraphs,
++ * bullet/numbered lists, tables, fenced code, blockquotes, horizontal rules, and inline
++ * code / bold / italic / links — with no dependency. Everything is HTML-escaped before
++ * any inline formatting is applied, so document text can never inject markup.
++ */
++
++const express = require('express');
++const fs = require('fs');
++const path = require('path');
++
++const router = express.Router();
++const DOCS_DIR = path.join(__dirname, '../../docs');
++const GITHUB_BLOB = 'https://github.com/OpenVibers/OpenVibe.Live/blob/main';
++
++// ── Helpers ──────────────────────────────────────────────────
++
++function escapeHtml(text) {
++ return String(text)
++ .replace(/&/g, '&amp;')
++ .replace(/</g, '&lt;')
++ .replace(/>/g, '&gt;')
++ .replace(/"/g, '&quot;');
++}
++
++// GitHub-style heading anchor: lowercase, drop punctuation, spaces → hyphens.
++function slugify(text, used) {
++ const base = String(text)
++ .toLowerCase()
++ .replace(/<[^>]+>/g, '')
++ .replace(/&[a-z]+;|&#\d+;/g, '')
++ .replace(/[^\p{L}\p{N}\s-]/gu, '')
++ .trim()
++ // Each space becomes a hyphen — GitHub does not collapse runs, so "VODs & Clips"
++ // is "vods--clips" there, and anchors must round-trip between the two.
++ .replace(/\s/g, '-');
++ let slug = base || 'section';
++ let n = 1;
++ while (used.has(slug)) slug = `${base}-${n++}`;
++ used.add(slug);
++ return slug;
++}
++
++/**
++ * Rewrite a link target from the Markdown source so it works from /docs/<name>:
++ * other-doc.md#x → /docs/other-doc#x (served here)
++ * ../public/x.html → /x.html (served from the web root)
++ * ../SETUP.md, hardware/README.md, … → the GitHub blob URL
++ * Absolute URLs and same-page anchors pass through untouched.
++ */
++function rewriteHref(href) {
++ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(href)) return href;
++ const [target, hash] = href.split('#');
++ const anchor = hash ? `#${hash}` : '';
++ const clean = target.replace(/^\.\//, '');
++ const docMatch = clean.match(/^([a-z0-9-]+)\.md$/i);
++ if (docMatch) return `/docs/${docMatch[1]}${anchor}`;
++ const publicMatch = clean.match(/^\.\.\/public\/(.+)$/);
++ if (publicMatch) return `/${publicMatch[1]}${anchor}`;
++ const repoPath = clean.startsWith('../') ? clean.slice(3) : `docs/${clean}`;
++ return `${GITHUB_BLOB}/${repoPath}${anchor}`;
++}
++
++/**
++ * Inline formatting on already-escaped text. Code spans are lifted out first so that
++ * `**`, `*`, `[`, and `<https://…>` inside them are left alone.
++ */
++function renderInline(escaped) {
++ const codes = [];
++ let text = escaped.replace(/`([^`]+)`/g, (_, code) => {
++ codes.push(`<code>${code}</code>`);
++ return `\u0000${codes.length - 1}\u0000`;
++ });
++ text = text
++ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, href) => {
++ const target = rewriteHref(href.replace(/&amp;/g, '&'));
++ const external = /^https?:\/\//i.test(target) && !target.startsWith('https://openvibe.live');
++ return `<a href="${escapeHtml(target)}"${external ? ' rel="noopener"' : ''}>${label}</a>`;
++ })
++ .replace(/&lt;(https?:\/\/[^\s&]+)&gt;/g, (_, url) => `<a href="${url}">${url}</a>`)
++ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
++ .replace(/(^|[\s(])\*([^*\s][^*]*?)\*(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>');
++ return text.replace(/\u0000(\d+)\u0000/g, (_, i) => codes[Number(i)]);
++}
++
++function splitTableRow(line) {
++ return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
++}
++
++// ── Block renderer ───────────────────────────────────────────
++
++function renderMarkdown(markdown) {
++ const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
++ const out = [];
++ const usedSlugs = new Set();
++ const headings = [];
++ let title = null;
++ let i = 0;
++
++ const flushParagraph = (buf) => {
++ if (buf.length) out.push(`<p>${renderInline(escapeHtml(buf.join(' ')))}</p>`);
++ buf.length = 0;
++ };
++
++ const para = [];
++ while (i < lines.length) {
++ const line = lines[i];
++
++ // Fenced code
++ const fence = line.match(/^```\s*([\w+-]*)\s*$/);
++ if (fence) {
++ flushParagraph(para);
++ const lang = fence[1];
++ const code = [];
++ i++;
++ while (i < lines.length && !/^```\s*$/.test(lines[i])) code.push(lines[i++]);
++ i++; // closing fence
++ out.push(`<pre${lang ? ` data-lang="${escapeHtml(lang)}"` : ''}><code>${escapeHtml(code.join('\n'))}</code></pre>`);
++ continue;
++ }
++
++ // Heading
++ const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
++ if (heading) {
++ flushParagraph(para);
++ const level = heading[1].length;
++ const html = renderInline(escapeHtml(heading[2]));
++ const id = slugify(heading[2], usedSlugs);
++ if (level === 1 && title === null) title = heading[2];
++ if (level === 2) headings.push({ id, text: heading[2] });
++ out.push(`<h${level} id="${id}">${html}<a class="anchor" href="#${id}" aria-label="Link to this section">#</a></h${level}>`);
++ i++;
++ continue;
++ }
++
++ // Horizontal rule
++ if (/^(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
++ flushParagraph(para);
++ out.push('<hr>');
++ i++;
++ continue;
++ }
++
++ // Blockquote
++ if (/^>/.test(line)) {
++ flushParagraph(para);
++ const quote = [];
++ while (i < lines.length && /^>/.test(lines[i])) quote.push(lines[i++].replace(/^>\s?/, ''));
++ // Blank quote lines separate paragraphs; consecutive lines join.
++ const paras = quote.join('\n').split(/\n\s*\n/).map(p => p.replace(/\n/g, ' ').trim()).filter(Boolean);
++ out.push(`<blockquote>${paras.map(p => `<p>${renderInline(escapeHtml(p))}</p>`).join('')}</blockquote>`);
++ continue;
++ }
++
++ // Table: header row followed by a |---| separator
++ if (/^\|/.test(line) && i + 1 < lines.length && /^\|?\s*:?-{2,}/.test(lines[i + 1])) {
++ flushParagraph(para);
++ const header = splitTableRow(line);
++ i += 2;
++ const rows = [];
++ while (i < lines.length && /^\|/.test(lines[i])) rows.push(splitTableRow(lines[i++]));
++ const cell = (tag, c) => `<${tag}>${renderInline(escapeHtml(c))}</${tag}>`;
++ out.push('<div class="table-wrap"><table>'
++ + `<thead><tr>${header.map(c => cell('th', c)).join('')}</tr></thead>`
++ + `<tbody>${rows.map(r => `<tr>${r.map(c => cell('td', c)).join('')}</tr>`).join('')}</tbody>`
++ + '</table></div>');
++ continue;
++ }
++
++ // Lists (bulleted or numbered); indented lines continue the current item.
++ const listStart = line.match(/^(\s*)([-*+]|\d+[.)])\s+/);
++ if (listStart && listStart[1].length === 0) {
++ flushParagraph(para);
++ const ordered = /\d/.test(listStart[2]);
++ const items = [];
++ while (i < lines.length) {
++ const m = lines[i].match(/^([-*+]|\d+[.)])\s+(.*)$/);
++ if (m && (/\d/.test(m[1]) === ordered)) {
++ items.push([m[2]]);
++ i++;
++ } else if (items.length && /^\s{2,}\S/.test(lines[i])) {
++ items[items.length - 1].push(lines[i].trim());
++ i++;
++ } else {
++ break;
++ }
++ }
++ const tag = ordered ? 'ol' : 'ul';
++ out.push(`<${tag}>${items.map(parts => `<li>${parts.map(p => renderInline(escapeHtml(p))).join('<br>')}</li>`).join('')}</${tag}>`);
++ continue;
++ }
++
++ // Blank line ends a paragraph
++ if (!line.trim()) {
++ flushParagraph(para);
++ i++;
++ continue;
++ }
++
++ para.push(line.trim());
++ i++;
++ }
++ flushParagraph(para);
++
++ return { html: out.join('\n'), title, headings };
++}
++
++// ── Page shell ───────────────────────────────────────────────
++
++function renderPage({ name, title, html, headings }) {
++ const pageTitle = title ? `${title} — OpenVibe.Live Docs` : 'OpenVibe.Live Docs';
++ const toc = headings.length
++ ? `<nav class="toc" aria-label="On this page">${headings.map(h => `<a href="#${h.id}">${escapeHtml(h.text)}</a>`).join('')}</nav>`
++ : '';
++ return `<!DOCTYPE html>
++<html lang="en">
++<head>
++ <meta charset="UTF-8">
++ <meta name="viewport" content="width=device-width, initial-scale=1.0">
++ <title>${escapeHtml(pageTitle)}</title>
++ <link rel="icon" href="/assets/favicon.ico">
++ <style>
++ :root { --bg: #0d0d0f; --bg-2: #16161a; --bg-3: #1e1e24; --text: #e8e6e3; --text-2: #9a9a9a; --muted: #666;
++ --accent: #8b5cf6; --accent-light: #a78bfa; --border: #2a2a32; }
++ * { box-sizing: border-box; }
++ html, body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
++ a { color: var(--accent-light); }
++ .top { display: flex; align-items: center; gap: 14px; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--bg-2); font-size: 0.9rem; }
++ .top .brand { font-weight: 700; color: var(--text); text-decoration: none; display: flex; align-items: center; gap: 8px; }
++ .top .brand::before { content: ''; width: 18px; height: 18px; border-radius: 5px; background: linear-gradient(135deg, #8b5cf6, #6d28d9); }
++ .top .sep { color: var(--muted); }
++ .top .right { margin-left: auto; display: flex; gap: 14px; }
++ main { max-width: 880px; margin: 0 auto; padding: 24px 20px 64px; }
++ h1 { font-size: 1.9rem; margin: 0 0 12px; line-height: 1.25; }
++ h2 { font-size: 1.35rem; margin: 2.2em 0 0.6em; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
++ h3 { font-size: 1.1rem; margin: 1.8em 0 0.5em; }
++ h4 { font-size: 1rem; margin: 1.5em 0 0.4em; }
++ h1, h2, h3, h4 { scroll-margin-top: 16px; }
++ .anchor { margin-left: 8px; color: var(--muted); text-decoration: none; opacity: 0; font-weight: 400; }
++ h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, h4:hover .anchor, .anchor:focus { opacity: 1; }
++ p, ul, ol, blockquote, pre, .table-wrap { margin: 0 0 1em; }
++ li { margin: 0.25em 0; }
++ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--bg-3); padding: 1px 5px; border-radius: 4px; }
++ pre { background: var(--bg-2); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; overflow-x: auto; position: relative; }
++ pre code { background: none; padding: 0; font-size: 0.85rem; line-height: 1.5; }
++ pre[data-lang]::before { content: attr(data-lang); position: absolute; top: 6px; right: 12px; font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
++ blockquote { border-left: 3px solid var(--accent); background: rgba(139, 92, 246, 0.08); padding: 10px 16px; border-radius: 0 8px 8px 0; }
++ blockquote p:last-child { margin: 0; }
++ .table-wrap { overflow-x: auto; }
++ table { border-collapse: collapse; width: 100%; font-size: 0.92rem; }
++ th, td { text-align: left; padding: 8px 10px; border: 1px solid var(--border); vertical-align: top; }
++ th { background: var(--bg-2); }
++ tr:nth-child(even) td { background: rgba(255, 255, 255, 0.02); }
++ hr { border: 0; border-top: 1px solid var(--border); margin: 2em 0; }
++ .toc { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 0.85rem; margin: 0 0 24px; padding: 12px 14px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 10px; }
++ .toc a { color: var(--text-2); text-decoration: none; }
++ .toc a:hover { color: var(--accent-light); }
++ .foot { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--border); font-size: 0.85rem; color: var(--text-2); }
++ </style>
++</head>
++<body>
++<header class="top">
++ <a class="brand" href="/">OpenVibe.Live</a><span class="sep">/</span><a href="/docs">Docs</a>
++ <div class="right"><a href="${GITHUB_BLOB}/docs/${escapeHtml(name)}.md" rel="noopener">Source on GitHub</a></div>
++</header>
++<main>
++${toc}
++${html}
++<p class="foot">This page is rendered from <code>docs/${escapeHtml(name)}.md</code> in the <a href="https://github.com/OpenVibers/OpenVibe.Live" rel="noopener">OpenVibe.Live repository</a>. Found a mistake? Edit it there.</p>
++</main>
++</body>
++</html>`;
++}
++
++// ── Routes ───────────────────────────────────────────────────
++
++function loadDoc(name) {
++ if (!/^[a-z0-9-]+$/i.test(name)) return null;
++ const file = path.join(DOCS_DIR, `${name}.md`);
++ if (!file.startsWith(DOCS_DIR + path.sep)) return null;
++ try {
++ return fs.readFileSync(file, 'utf8');
++ } catch {
++ return null;
++ }
++}
++
++function sendDoc(req, res, name) {
++ const markdown = loadDoc(name);
++ if (markdown === null) return res.status(404).type('text/plain').send('No such doc');
++ const rendered = renderMarkdown(markdown);
++ res.set('Cache-Control', 'public, max-age=300');
++ res.type('html').send(renderPage({ name, ...rendered }));
++}
++
++router.get('/', (req, res) => sendDoc(req, res, 'README'));
++router.get('/:name.md', (req, res) => sendDoc(req, res, req.params.name));
++router.get('/:name', (req, res) => sendDoc(req, res, req.params.name));
++
++module.exports = router;
++module.exports._renderMarkdown = renderMarkdown;
++module.exports._rewriteHref = rewriteHref;
++module.exports._slugify = slugify;
ddiff --git a/server/index.js b/server/index.js
iindex 40de8e77..353c65c2 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -213,7 +213,7 @@ app.set('trust proxy', 2); // Two hops: Cloudflare → nginx → Node
// RTMP FLV is now proxied same-origin via /api/streams/rtmp-proxy/:id.flv — no external CSP entry needed
// The hosted browser WHIP publisher (/whip-publisher.html) POSTs its SDP offer to the
--// dedicated WHIP host when one is configured (whip.openvibe.live), which is a different
++// dedicated WHIP host when one is configured (ingest.openvibe.live), which is a different
// origin from the page itself and would otherwise be blocked by connect-src 'self'.
// Resolved per request, not at boot: config.whip is (re)filled by the URL-registry refresh
// in start(), which runs after this middleware is built — a value captured here would be
@@@ -640,6 +640,12 @@ app.post('/api/admin/broadcast', requireAuth, permissions.requireAdmin, (req, re
}
});
++// ── Docs ─────────────────────────────────────────────────────
++// docs/*.md rendered as HTML at /docs/<name> (+ /docs/<name>.md) with GitHub-compatible
++// heading anchors, so https://openvibe.live/docs/whip#publishing-from-a-browser is a
++// link people can be sent to without leaving the site.
++app.use('/docs', require('./docs/routes'));
++
// ── OBS Overlay Widgets ──────────────────────────────────────
// Modular system: /obs/<widget>/<username>
// Each widget is a standalone HTML page designed for OBS browser sources.
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex 763351ca..31d1b4ef 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -449,6 +449,13 @@ async function buildSitemap() {
const urls = [];
const seenChannels = new Set();
const statics = [['/', 'daily', '1.0'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5']];
++ // Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.
++ try {
++ for (const f of require('fs').readdirSync(require('path').join(__dirname, '../../docs'))) {
++ if (!f.endsWith('.md')) continue;
++ statics.push([f === 'README.md' ? '/docs' : `/docs/${f.slice(0, -3)}`, 'weekly', '0.5']);
++ }
++ } catch { /* docs folder absent in some deploys */ }
for (const [u, cf, pr] of statics) urls.push(_urlTag(u, null, cf, pr));
const page = async (fetch, per, cap, emit) => {
let off = 0;
ddiff --git a/test/docs-render.test.js b/test/docs-render.test.js
nnew file mode 100644
iindex 00000000..c9d5653b
---- /dev/null
++++ b/test/docs-render.test.js
@@@ -0,0 +1,87 @@
++'use strict';
++
++// Regression tests for the on-site docs renderer (server/docs/routes.js).
++// Every docs/*.md must render without leaking raw Markdown, heading anchors must match
++// GitHub's slugs (links are shared between the site and the repo), and relative links
++// must be rewritten to places that exist on the site.
++
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { _renderMarkdown: render, _rewriteHref: rewriteHref, _slugify: slugify } = require('../server/docs/routes');
++
++// ── Heading slugs (GitHub-compatible) ──
++const used = new Set();
++assert.strictEqual(slugify('Publishing from a browser', used), 'publishing-from-a-browser');
++assert.strictEqual(slugify('Cross-origin (CORS)', used), 'cross-origin-cors');
++assert.strictEqual(slugify('VODs & Clips', used), 'vods--clips');
++assert.strictEqual(slugify('Publishing from a browser', used), 'publishing-from-a-browser-1', 'duplicate headings get a numeric suffix');
++console.log('✅ heading slugs match GitHub');
++
++// ── Link rewriting ──
++assert.strictEqual(rewriteHref('broadcasting.md'), '/docs/broadcasting');
++assert.strictEqual(rewriteHref('whip.md#publishing-from-a-browser'), '/docs/whip#publishing-from-a-browser');
++assert.strictEqual(rewriteHref('../public/whip-publisher.html'), '/whip-publisher.html');
++assert.strictEqual(rewriteHref('../SETUP.md'), 'https://github.com/OpenVibers/OpenVibe.Live/blob/main/SETUP.md');
++assert.strictEqual(rewriteHref('https://example.com/x'), 'https://example.com/x');
++assert.strictEqual(rewriteHref('#endpoint'), '#endpoint');
++assert.strictEqual(rewriteHref('/whip-publisher.html'), '/whip-publisher.html');
++console.log('✅ relative links rewritten to on-site or GitHub targets');
++
++// ── Inline formatting + escaping ──
++const inline = render('Text (CORS `*`) and `fetch()`. **bold**, `code **kept**`, <https://a.b/c>, and <script>x</script>.').html;
++assert.ok(inline.includes('(CORS <code>*</code>)'), 'code span with no stray placeholder spaces');
++assert.ok(inline.includes('<code>code **kept**</code>'), 'markdown inside code spans is left alone');
++assert.ok(inline.includes('<strong>bold</strong>'));
++assert.ok(inline.includes('<a href="https://a.b/c">https://a.b/c</a>'), 'autolinks');
++assert.ok(inline.includes('&lt;script&gt;x&lt;/script&gt;'), 'raw HTML is escaped, never emitted');
++assert.ok(!inline.includes(String.fromCharCode(0)), 'no placeholder bytes leak into output');
++console.log('✅ inline formatting and HTML escaping');
++
++// ── Blocks ──
++const blocks = render([
++ '# Title',
++ '',
++ '> quoted line one',
++ '> quoted line two',
++ '',
++ '| A | B |',
++ '|---|---|',
++ '| 1 | `2` |',
++ '',
++ '1. first',
++ ' continued',
++ '2. second',
++ '',
++ '- bullet',
++ '',
++ '```bash',
++ 'curl -X POST "<url>"',
++ '```',
++].join('\n'));
++assert.strictEqual(blocks.title, 'Title');
++assert.ok(blocks.html.includes('<blockquote><p>quoted line one quoted line two</p></blockquote>'));
++assert.ok(blocks.html.includes('<th>A</th>') && blocks.html.includes('<td><code>2</code></td>'), 'tables');
++assert.ok(blocks.html.includes('<ol><li>first<br>continued</li><li>second</li></ol>'), 'ordered list with continuation line');
++assert.ok(blocks.html.includes('<ul><li>bullet</li></ul>'));
++assert.ok(blocks.html.includes('<pre data-lang="bash"><code>curl -X POST &quot;&lt;url&gt;&quot;</code></pre>'), 'fenced code is escaped verbatim');
++console.log('✅ block elements');
++
++// ── Every shipped doc renders cleanly with the anchors people link to ──
++const docsDir = path.join(__dirname, '../docs');
++const leakPatterns = [/```/, /\*\*/, /^\s*\|/m, /\]\(/, /^#{1,6} /m];
++for (const file of fs.readdirSync(docsDir).filter(f => f.endsWith('.md'))) {
++ const result = render(fs.readFileSync(path.join(docsDir, file), 'utf8'));
++ assert.ok(result.title, `${file}: has an H1 title`);
++ const textOnly = result.html
++ .replace(/<pre[\s\S]*?<\/pre>/g, '')
++ .replace(/<code>[\s\S]*?<\/code>/g, '')
++ .replace(/<[^>]+>/g, '');
++ for (const re of leakPatterns) assert.ok(!re.test(textOnly), `${file}: raw markdown leaked (${re})`);
++}
++const whip = render(fs.readFileSync(path.join(docsDir, 'whip.md'), 'utf8')).html;
++assert.ok(whip.includes('id="publishing-from-a-browser"'), 'the browser-publishing anchor that is linked publicly must exist');
++assert.ok(whip.includes('href="/whip-publisher.html"'), 'link to the hosted publisher resolves on-site');
++console.log('✅ all docs/*.md render without leaking markdown; public anchors present');
++
++console.log('\n✅ All docs renderer tests passed');