Skip to content

Commit 8a0d4e0

Browse files
beacon: server-rendered OGP + crawlable per-profile URLs
Social crawlers don't run JS, so the hash-routed SPA had no shareable previews. Now: - renderShell() injects <title> + Open Graph + Twitter meta at an <!--OGP--> marker in the shell. - GET / serves home OGP; GET /p/<pubkey> serves that identity's OGP (og:title=name, og:description=about, og:image=picture, canonical og:url). - Client routing moves to real /p/<hex> paths via pushState (+ delegated nav for cards and follow-graph dots); legacy #<hex> links still resolve. - express.static index:false so the / and /p routes own shell rendering.
1 parent 25230df commit 8a0d4e0

2 files changed

Lines changed: 73 additions & 13 deletions

File tree

public/index.html

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
<head>
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6-
<title>Beacon · a directory of did:nostr identities</title>
7-
<meta name="description" content="A living directory of did:nostr identities — profiles, the follow graph, and a verifiable DID document for every key.">
6+
<!--OGP-->
87
<link rel="preconnect" href="https://fonts.googleapis.com">
98
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
109
<link href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400..600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
@@ -36,7 +35,7 @@
3635

3736
/* grid of profiles */
3837
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem;margin-top:1rem}
39-
.card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:1.2rem;cursor:pointer;
38+
.card{display:block;color:inherit;background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:1.2rem;cursor:pointer;
4039
transition:transform .18s ease,box-shadow .18s ease,border-color .18s;box-shadow:0 1px 2px rgba(20,20,30,.04)}
4140
.card:hover{transform:translateY(-3px);box-shadow:0 12px 30px rgba(80,70,160,.13);border-color:#dcd8f5}
4241
.av{position:relative;width:56px;height:56px;border-radius:50%;background:var(--accent-soft);display:grid;place-items:center;
@@ -133,19 +132,18 @@ <h1>A directory of <em style="font-style:italic">did:nostr</em> identities</h1>
133132
function cardHtml(p) {
134133
const c = content(p);
135134
const ct = p._counts || {};
136-
return `<div class="card" data-pk="${p.pubkey}">
135+
return `<a class="card" href="/p/${p.pubkey}" data-pk="${p.pubkey}">
137136
${avatar(c, p.pubkey)}
138137
<div class="name">${esc(c.name || c.display_name || short(p.pubkey))}</div>
139138
${c.nip05 ? `<div class="nip05">${esc(c.nip05)}</div>` : ''}
140139
<div class="counts"><strong>${fmt(ct.followers)}</strong> followers · <strong>${fmt(ct.following)}</strong> following</div>
141140
${c.about ? `<div class="about">${esc(c.about)}</div>` : ''}
142141
<div class="did">did:nostr:${short(p.pubkey)}</div>
143-
</div>`;
142+
</a>`;
144143
}
145144

146145
function renderGrid(profiles, label) {
147146
view.innerHTML = `${label ? `<div class="seclabel">${label}</div>` : ''}<div class="grid">${profiles.map(cardHtml).join('')}</div>`;
148-
view.querySelectorAll('.card').forEach((el) => { el.onclick = () => { location.hash = el.dataset.pk; }; });
149147
}
150148

151149
async function loadStats() {
@@ -199,28 +197,40 @@ <h2>${esc(c.name || short(pk))}</h2>
199197
<div class="sections">
200198
<div class="panel"><h3>DID document</h3><pre>${highlight(didOnly(doc))}</pre></div>
201199
<div class="panel"><h3>Following · ${follows.length}</h3>
202-
${follows.length ? `<div class="follows">${follows.slice(0, 96).map((d) => { const fk = d.replace('did:nostr:', ''); return `<a class="fav" style="${gradStyle(fk)}" title="${esc(fk)}" href="#${fk}"></a>`; }).join('')}</div>`
200+
${follows.length ? `<div class="follows">${follows.slice(0, 96).map((d) => { const fk = d.replace('did:nostr:', ''); return `<a class="fav" data-pk="${fk}" style="${gradStyle(fk)}" title="${esc(fk)}" href="/p/${fk}"></a>`; }).join('')}</div>`
203201
: '<div style="color:var(--faint);font-size:.9rem">No follow list indexed.</div>'}
204202
</div>
205203
</div>
206204
</div>`;
207-
$('#back').onclick = () => { location.hash = ''; };
205+
$('#back').onclick = () => navigate('/');
208206
}
209207

208+
function navigate(path) { if (location.pathname !== path) history.pushState({}, '', path); route(); }
209+
210210
let searchTimer;
211211
function onSearch() {
212212
const q = $('#q').value.trim();
213213
clearTimeout(searchTimer);
214-
searchTimer = setTimeout(() => { if (!q) route(); else searchView(q); }, 220);
214+
searchTimer = setTimeout(() => { if (!q) (location.pathname === '/' ? home() : navigate('/')); else searchView(q); }, 220);
215215
}
216216

217217
function route() {
218-
const pk = location.hash.slice(1);
219-
if (/^[0-9a-f]{64}$/.test(pk)) profile(pk); else home();
218+
const m = location.pathname.match(/^\/p\/([0-9a-f]{64})$/i);
219+
if (m) return profile(m[1].toLowerCase());
220+
const h = location.hash.slice(1); // legacy #<hex> links still resolve
221+
if (/^[0-9a-f]{64}$/.test(h)) return profile(h);
222+
return home();
220223
}
221224

225+
// delegated SPA nav for any [data-pk] link (cards, follow dots)
226+
document.addEventListener('click', (e) => {
227+
const el = e.target.closest('a[data-pk]');
228+
if (!el || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
229+
e.preventDefault();
230+
navigate('/p/' + el.dataset.pk);
231+
});
222232
$('#q').addEventListener('input', onSearch);
223-
window.addEventListener('hashchange', route);
233+
window.addEventListener('popstate', route);
224234
loadStats();
225235
route();
226236
</script>

src/server.js

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,70 @@
11
// Thin read API over the indexed social graph.
22
import express from 'express';
33
import path from 'node:path';
4+
import { readFileSync } from 'node:fs';
45
import { fileURLToPath } from 'node:url';
56
import { getProfile, getFollows, getRelays, recentProfiles, connect, stats, searchProfiles, enrichCounts, followerCount } from './db.js';
67
import { buildDidDocument } from './diddoc.js';
78
import 'dotenv/config';
89

910
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
11+
const SITE = process.env.SITE_URL || 'https://nostr.social';
12+
13+
// ---- server-rendered OGP (social crawlers don't run JS) --------------------
14+
const escAttr = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
15+
let SHELL;
16+
const shell = () => (SHELL ??= readFileSync(path.join(PUBLIC, 'index.html'), 'utf8'));
17+
18+
// Inject <title> + Open Graph / Twitter meta into the SPA shell at <!--OGP-->.
19+
function renderShell(meta = {}) {
20+
const m = {
21+
title: 'Beacon · a directory of did:nostr identities',
22+
description: 'Profiles, the follow graph, and a verifiable DID document for every did:nostr key — indexed live from Nostr.',
23+
url: `${SITE}/`, type: 'website', image: '', ...meta,
24+
};
25+
const tags = [
26+
`<title>${escAttr(m.title)}</title>`,
27+
`<meta name="description" content="${escAttr(m.description)}">`,
28+
`<meta property="og:site_name" content="Beacon">`,
29+
`<meta property="og:type" content="${escAttr(m.type)}">`,
30+
`<meta property="og:title" content="${escAttr(m.title)}">`,
31+
`<meta property="og:description" content="${escAttr(m.description)}">`,
32+
`<meta property="og:url" content="${escAttr(m.url)}">`,
33+
m.image && `<meta property="og:image" content="${escAttr(m.image)}">`,
34+
`<meta name="twitter:card" content="${m.image ? 'summary_large_image' : 'summary'}">`,
35+
`<meta name="twitter:title" content="${escAttr(m.title)}">`,
36+
`<meta name="twitter:description" content="${escAttr(m.description)}">`,
37+
m.image && `<meta name="twitter:image" content="${escAttr(m.image)}">`,
38+
].filter(Boolean).join('\n ');
39+
return shell().replace('<!--OGP-->', tags);
40+
}
1041

1142
export async function startServer(port = process.env.PORT || 3000) {
1243
await connect();
1344
const app = express();
1445
// Public read-only resolver: allow cross-origin fetches so browser-based
1546
// did:nostr resolvers can read the documents.
1647
app.use((_req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); next(); });
17-
app.use(express.static(PUBLIC));
48+
app.use(express.static(PUBLIC, { index: false }));
49+
50+
// home shell (default OGP)
51+
app.get('/', (_req, res) => res.type('html').send(renderShell()));
52+
53+
// per-profile shell with that identity's OGP (crawlable canonical URL)
54+
app.get('/p/:id', async (req, res, next) => {
55+
try {
56+
const id = String(req.params.id).toLowerCase();
57+
if (!/^[0-9a-f]{64}$/.test(id)) return res.type('html').send(renderShell());
58+
const p = await getProfile(id);
59+
let c = {}; try { c = JSON.parse(p?.content || '{}'); } catch { /* malformed */ }
60+
const name = c.name || c.display_name || `did:nostr:${id.slice(0, 8)}…`;
61+
const about = String(c.about || `A did:nostr identity · ${id.slice(0, 16)}…`).replace(/\s+/g, ' ').slice(0, 180);
62+
res.type('html').send(renderShell({
63+
title: `${name} · Beacon`, description: about, url: `${SITE}/p/${id}`,
64+
type: 'profile', image: c.picture || '',
65+
}));
66+
} catch (e) { next(e); }
67+
});
1868

1969
// Liveness/readiness: confirms Mongo is reachable (for haproxy httpchk + monitoring).
2070
app.get('/healthz', async (_req, res) => {

0 commit comments

Comments
 (0)