Skip to content

Commit 4e050ce

Browse files
committed
feat: 4chan-style preview titles with board names
Title now reads "/tv/ - Television & Film - <subject> - 5chan" and the description appends the board name + a site tagline, like 4chan. Board names come from a bundled directory-code -> title map refreshed from the 5chan repo; arbitrary boards fall back to the community address. Image preview is kept (embeds, not hosted by the client).
1 parent ba73220 commit 4e050ce

6 files changed

Lines changed: 217 additions & 24 deletions

File tree

data/board-titles.json

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"3": "/3/ - 3DCG",
3+
"a": "/a/ - Anime & Manga",
4+
"adv": "/adv/ - Advice",
5+
"an": "/an/ - Animals & Nature",
6+
"b": "/b/ - Random",
7+
"bant": "/bant/ - International/Random",
8+
"biz": "/biz/ - Business & Finance",
9+
"c": "/c/ - Anime/Cute",
10+
"ck": "/ck/ - Food & Cooking",
11+
"co": "/co/ - Comics & Cartoons",
12+
"diy": "/diy/ - Do It Yourself",
13+
"f": "/f/ - Flash",
14+
"fa": "/fa/ - Fashion",
15+
"fit": "/fit/ - Fitness",
16+
"g": "/g/ - Technology",
17+
"gd": "/gd/ - Graphic Design",
18+
"gif": "/gif/ - Adult GIF",
19+
"his": "/his/ - History & Humanities",
20+
"i": "/i/ - Oekaki",
21+
"ic": "/ic/ - Artwork/Critique",
22+
"int": "/int/ - International",
23+
"jp": "/jp/ - Otaku Culture",
24+
"k": "/k/ - Weapons",
25+
"lit": "/lit/ - Literature",
26+
"m": "/m/ - Mecha",
27+
"mlp": "/mlp/ - Pony",
28+
"mu": "/mu/ - Music",
29+
"n": "/n/ - Transportation",
30+
"news": "/news/ - Current News",
31+
"o": "/o/ - Auto",
32+
"out": "/out/ - Outdoors",
33+
"p": "/p/ - Photography",
34+
"po": "/po/ - Papercraft & Origami",
35+
"pol": "/pol/ - Politically Incorrect",
36+
"pw": "/pw/ - Professional Wrestling",
37+
"qst": "/qst/ - Quests",
38+
"r9k": "/r9k/ - ROBOT9002",
39+
"s5s": "/s5s/ - Shit 5chan Says",
40+
"sci": "/sci/ - Science & Math",
41+
"soc": "/soc/ - Cams & Meetups",
42+
"sp": "/sp/ - Sports",
43+
"t": "/t/ - Torrents",
44+
"tg": "/tg/ - Traditional Games",
45+
"toy": "/toy/ - Toys",
46+
"trv": "/trv/ - Travel",
47+
"tv": "/tv/ - Television & Film",
48+
"v": "/v/ - Video Games",
49+
"vg": "/vg/ - Video Game Generals",
50+
"vip": "/vip/ - Very Important Posts",
51+
"vm": "/vm/ - Video Games/Multiplayer",
52+
"vmg": "/vmg/ - Video Games/Mobile",
53+
"vp": "/vp/ - Pokémon",
54+
"vr": "/vr/ - Retro Games",
55+
"vrpg": "/vrpg/ - Video Games/RPG",
56+
"vst": "/vst/ - Video Games/Strategy",
57+
"vt": "/vt/ - Virtual YouTubers",
58+
"w": "/w/ - Anime/Wallpapers",
59+
"wg": "/wg/ - Wallpapers/General",
60+
"wsg": "/wsg/ - Worksafe GIF",
61+
"wsr": "/wsr/ - Worksafe Requests",
62+
"x": "/x/ - Paranormal",
63+
"xs": "/xs/ - Extreme Sports"
64+
}

lib/clients.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ export const CLIENTS = [
2828
hostnames: ['s.5chan.app'],
2929
appBaseUrl: 'https://5chan.app',
3030
siteName: '5chan',
31+
tagline: 'a serverless, adminless imageboard',
32+
// 5chan boards have nice directory names ("/tv/ - Television & Film"),
33+
// resolved from the directory code via lib/directories.js.
34+
directoryTitles: true,
3135
routes: [
3236
// /{board}/thread/{cid} -> rich thread preview (the common share link)
3337
{
@@ -39,9 +43,9 @@ export const CLIENTS = [
3943
// /{board}/catalog -> generic board preview
4044
{
4145
kind: 'page',
46+
pageKind: 'catalog',
4247
test: /^\/([^/]+)\/catalog\/?$/,
4348
app: (m) => `/#/${m[1]}/catalog`,
44-
title: (m) => `/${m[1]}/ catalog`,
4549
},
4650
// legacy /p/{address}/c/{cid} format (still valid)
4751
{
@@ -55,14 +59,12 @@ export const CLIENTS = [
5559
kind: 'page',
5660
test: /^\/p\/([^/]+)\/?$/,
5761
app: (m) => `/#/${m[1]}`,
58-
title: (m) => `/${m[1]}/`,
5962
},
6063
// bare /{board} (board home, /all, /subscriptions, /mod, ...)
6164
{
6265
kind: 'page',
6366
test: /^\/([^/]+)\/?$/,
6467
app: (m) => `/#/${m[1]}`,
65-
title: (m) => `/${m[1]}/`,
6668
},
6769
],
6870
},
@@ -78,14 +80,15 @@ export const CLIENTS = [
7880
hostnames: ['s.seedit.app'],
7981
appBaseUrl: 'https://seedit.app',
8082
siteName: 'seedit',
83+
tagline: 'a decentralized Reddit alternative',
8184
routes: [
8285
// /{community}/c/{cid} -> clean subdomain form
8386
{ kind: 'thread', test: /^\/([^/]+)\/c\/([^/]+)\/?$/, cid: (m) => m[2], app: (m) => `/#/s/${m[1]}/c/${m[2]}` },
8487
// /s/{community}/c/{cid} -> mirrors seedit's existing /s/ links
8588
{ kind: 'thread', test: /^\/s\/([^/]+)\/c\/([^/]+)\/?$/, cid: (m) => m[2], app: (m) => `/#/s/${m[1]}/c/${m[2]}` },
8689
// community pages
87-
{ kind: 'page', test: /^\/s\/([^/]+)\/?$/, app: (m) => `/#/s/${m[1]}`, title: (m) => `s/${m[1]}` },
88-
{ kind: 'page', test: /^\/([^/]+)\/?$/, app: (m) => `/#/s/${m[1]}`, title: (m) => `s/${m[1]}` },
90+
{ kind: 'page', test: /^\/s\/([^/]+)\/?$/, app: (m) => `/#/s/${m[1]}` },
91+
{ kind: 'page', test: /^\/([^/]+)\/?$/, app: (m) => `/#/s/${m[1]}` },
8992
],
9093
},
9194
]

lib/directories.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { readFileSync } from 'node:fs'
2+
import { fileURLToPath } from 'node:url'
3+
import Debug from 'debug'
4+
5+
const debug = Debug('bitsocial-previewer:directories')
6+
7+
// Maps a 5chan directory code to its display title, e.g. "tv" -> "/tv/ -
8+
// Television & Film". A snapshot is bundled (data/board-titles.json) and
9+
// refreshed from the 5chan repo in the background so new boards' names stay
10+
// current without a redeploy.
11+
const BUNDLE_PATH = fileURLToPath(new URL('../data/board-titles.json', import.meta.url))
12+
const REMOTE_URL =
13+
'https://raw.githubusercontent.com/bitsocialnet/5chan/master/src/data/5chan-directories/5chan-directories-defaults.json'
14+
const REFRESH_MS = 6 * 60 * 60 * 1000
15+
16+
let titles = new Map()
17+
try {
18+
titles = new Map(Object.entries(JSON.parse(readFileSync(BUNDLE_PATH, 'utf8'))))
19+
debug('loaded %d bundled board titles', titles.size)
20+
} catch (e) {
21+
debug('failed to load bundled board titles:', e?.message || e)
22+
}
23+
24+
// Directory code (e.g. "tv") -> "/tv/ - Television & Film", or null if the code
25+
// is not a known 5chan directory (arbitrary community addresses return null).
26+
export const getBoardTitle = (code) => (code && titles.get(code)) || null
27+
28+
const refresh = async () => {
29+
try {
30+
const res = await fetch(REMOTE_URL, { signal: AbortSignal.timeout(8000) })
31+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
32+
const dirs = (await res.json())?.directories
33+
if (!dirs || typeof dirs !== 'object') throw new Error('no directories in remote')
34+
const next = new Map()
35+
for (const [code, entry] of Object.entries(dirs)) {
36+
if (entry?.title) next.set(code, entry.title)
37+
}
38+
if (next.size) {
39+
titles = next
40+
debug('refreshed %d board titles from remote', titles.size)
41+
}
42+
} catch (e) {
43+
debug('board-title refresh failed (keeping current set):', e?.message || e)
44+
}
45+
}
46+
47+
// Best-effort, non-blocking; never throws.
48+
refresh()
49+
setInterval(refresh, REFRESH_MS).unref?.()

lib/html.js

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,50 @@ const truncate = (value, max) => {
1919
return s.length > max ? s.slice(0, max - 1).trimEnd() + '…' : s
2020
}
2121

22-
// opts: { client, appUrl, comment?, board?, image?, pageTitle? }
23-
export const buildPreviewHtml = ({ client, appUrl, comment, board, image, pageTitle }) => {
24-
const siteName = client.siteName
25-
const where = board ? `/${board}/` : comment?.communityAddress || ''
22+
const firstLine = (value) => (value ? String(value).split('\n').find((l) => l.trim()) : undefined)
23+
24+
const TITLE_SEP = ' - '
25+
const capitalize = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s)
2626

27-
const firstLine = comment?.content?.split('\n').find((l) => l.trim())
28-
const title = truncate(comment?.title || firstLine || pageTitle || siteName, 70) || siteName
27+
// Build the page title and description, modelled on 4chan's:
28+
// title: "/tv/ - Television & Film - <subject> - 5chan"
29+
// desc: "<post text> — "/tv/ - Television & Film" on 5chan, a serverless imageboard."
30+
// `label` is the board's display name (nice directory title, else the community
31+
// address); it can be null for non-board pages.
32+
const buildMeta = ({ kind, comment, label, siteName, tagline }) => {
33+
const onSite = tagline ? `${siteName}, ${tagline}` : siteName
34+
const context = label ? `"${label}" on ${onSite}` : onSite // board clause for descriptions
35+
const boardPart = label ? `${label}${TITLE_SEP}` : ''
2936

30-
let description
31-
if (comment) {
32-
const body = comment.content?.trim()
33-
description = truncate(body || (where ? `Thread in ${where} on ${siteName}` : `A post on ${siteName}`), 200)
34-
} else if (board) {
35-
description = `Browse ${where} on ${siteName}`
36-
} else {
37-
description = `Shared from ${siteName}`
37+
if (kind === 'thread' && comment) {
38+
const subject = truncate(comment.title || firstLine(comment.content), 65)
39+
const body = truncate(comment.content || comment.title, 120)
40+
return {
41+
title: subject ? `${boardPart}${subject}${TITLE_SEP}${siteName}` : `${boardPart}${siteName}`,
42+
description: body ? `${body}${context}.` : `${capitalize(context)}.`,
43+
}
44+
}
45+
if (kind === 'catalog') {
46+
return {
47+
title: `${boardPart}Catalog${TITLE_SEP}${siteName}`,
48+
description: label ? `Browse the "${label}" catalog on ${onSite}.` : `Browse ${onSite}.`,
49+
}
50+
}
51+
// board home / fallback
52+
return {
53+
title: label ? `${label}${TITLE_SEP}${siteName}` : siteName,
54+
description: `${capitalize(context)}.`,
3855
}
56+
}
57+
58+
// opts: { client, appUrl, comment?, board?, boardTitle?, image?, kind }
59+
// kind: 'thread' | 'catalog' | 'board' (default 'board')
60+
export const buildPreviewHtml = ({ client, appUrl, comment, board, boardTitle, image, kind = 'board' }) => {
61+
const siteName = client.siteName
62+
// Board display label: nice directory title > community address > /board/ > none.
63+
const label = boardTitle || comment?.communityAddress || (board ? `/${board}/` : null)
64+
65+
const { title, description } = buildMeta({ kind, comment, label, siteName, tagline: client.tagline })
3966

4067
const twitterCard = image ? 'summary_large_image' : 'summary'
4168
const favicon = `${client.appBaseUrl}/favicon.ico`
@@ -71,4 +98,4 @@ export const buildPreviewHtml = ({ client, appUrl, comment, board, image, pageTi
7198
</html>`
7299
}
73100

74-
export const __test__ = { escapeHtml, jsString, truncate }
101+
export const __test__ = { escapeHtml, jsString, truncate, buildMeta }

start.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getClient, matchRoute } from './lib/clients.js'
55
import { getComment } from './lib/pkc.js'
66
import { commentMediaUrl, scrapeLinkImage } from './lib/media.js'
77
import { buildPreviewHtml } from './lib/html.js'
8+
import { getBoardTitle } from './lib/directories.js'
89

910
const debug = Debug('bitsocial-previewer:server')
1011
Debug.enable(process.env.DEBUG || 'bitsocial-previewer:*')
@@ -43,24 +44,26 @@ app.get('*', async (req, res) => {
4344
const { route, m } = matched
4445
const board = m[1]
4546
const appUrl = client.appBaseUrl + route.app(m)
47+
// Nice board name for clients that have a directory (5chan); null otherwise.
48+
const boardTitle = client.directoryTitles ? getBoardTitle(board) : null
4649

4750
if (route.kind === 'thread') {
4851
const cid = route.cid(m)
4952
try {
5053
const comment = await getComment(cid)
5154
let image = commentMediaUrl(comment)
5255
if (!image && comment.link) image = await scrapeLinkImage(comment.link)
53-
return send(res, buildPreviewHtml({ client, appUrl, comment, board, image }), ONE_YEAR)
56+
return send(res, buildPreviewHtml({ client, appUrl, comment, board, boardTitle, image, kind: 'thread' }), ONE_YEAR)
5457
} catch (e) {
5558
// Graceful fallback: still redirect to the app with a generic card, but
5659
// with a short TTL so the rich preview can appear once the cid resolves.
5760
debug('getComment failed for', cid, '-', e?.message || e)
58-
return send(res, buildPreviewHtml({ client, appUrl, board }), FIVE_MIN)
61+
return send(res, buildPreviewHtml({ client, appUrl, board, boardTitle, kind: 'thread' }), FIVE_MIN)
5962
}
6063
}
6164

62-
// page (board/catalog/home) -> generic card
63-
return send(res, buildPreviewHtml({ client, appUrl, board, pageTitle: route.title?.(m) }), FIVE_MIN)
65+
// page (board home or catalog) -> generic card
66+
return send(res, buildPreviewHtml({ client, appUrl, board, boardTitle, kind: route.pageKind || 'board' }), FIVE_MIN)
6467
})
6568

6669
app

test/clients.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,58 @@ test('preview HTML escapes user content (no tag/attribute injection)', () => {
7676
appUrl: 'https://5chan.app/#/biz/thread/QmAbc',
7777
comment: { title: 'pwn "><img src=x onerror=alert(1)>', content: 'hi' },
7878
board: 'biz',
79+
kind: 'thread',
7980
})
8081
assert.ok(!html.includes('<img src=x'), 'raw injected tag must not survive')
8182
assert.ok(html.includes('&lt;img src=x'), 'injected tag must be escaped')
8283
})
8384

85+
test('5chan thread builds a 4chan-style title + description with the board name', () => {
86+
const html = buildPreviewHtml({
87+
client: { siteName: '5chan', appBaseUrl: 'https://5chan.app', tagline: 'a serverless, adminless imageboard' },
88+
appUrl: 'https://5chan.app/#/tv/thread/QmX',
89+
comment: { title: 'Disclosure Day', content: 'all this UFO craze is fake', communityAddress: 'television-and-film.bso' },
90+
board: 'tv',
91+
boardTitle: '/tv/ - Television & Film',
92+
image: 'https://i.imgur.com/x.png',
93+
kind: 'thread',
94+
})
95+
assert.ok(
96+
html.includes('og:title" content="/tv/ - Television &amp; Film - Disclosure Day - 5chan"'),
97+
'title = board name + subject + site',
98+
)
99+
assert.ok(
100+
html.includes(
101+
'og:description" content="all this UFO craze is fake — &quot;/tv/ - Television &amp; Film&quot; on 5chan, a serverless, adminless imageboard."',
102+
),
103+
'description = post text + board tagline',
104+
)
105+
assert.ok(html.includes('twitter:card" content="summary_large_image"'), 'image -> large card')
106+
})
107+
108+
test('thread on an arbitrary board falls back to the community address', () => {
109+
const html = buildPreviewHtml({
110+
client: { siteName: '5chan', appBaseUrl: 'https://5chan.app', tagline: 'a serverless, adminless imageboard' },
111+
appUrl: 'https://5chan.app/#/cool.bso/thread/QmX',
112+
comment: { title: 'hello', content: 'hi', communityAddress: 'cool.bso' },
113+
board: 'cool.bso',
114+
boardTitle: null,
115+
kind: 'thread',
116+
})
117+
assert.ok(html.includes('og:title" content="cool.bso - hello - 5chan"'), 'uses community address as board label')
118+
})
119+
120+
test('catalog page uses the directory name', () => {
121+
const html = buildPreviewHtml({
122+
client: { siteName: '5chan', appBaseUrl: 'https://5chan.app', tagline: 'a serverless, adminless imageboard' },
123+
appUrl: 'https://5chan.app/#/tv/catalog',
124+
board: 'tv',
125+
boardTitle: '/tv/ - Television & Film',
126+
kind: 'catalog',
127+
})
128+
assert.ok(html.includes('og:title" content="/tv/ - Television &amp; Film - Catalog - 5chan"'), 'catalog title')
129+
})
130+
84131
test('redirect script cannot break out of the <script> tag', () => {
85132
const html = buildPreviewHtml({
86133
client: { siteName: '5chan', appBaseUrl: 'https://5chan.app' },

0 commit comments

Comments
 (0)