Skip to content

Commit 448179d

Browse files
committed
feat: 4chan-order titles, display-title fallback, no-cache previews
- Title leads with directory code + post subject, then board name: '/tv/ - <subject> - Television & Film - 5chan' - Display title falls back: post subject -> content excerpt -> link, capped at 50 chars - Serve previews with 'Cache-Control: no-cache' (always revalidate via ETag) so format changes always show without a cache-buster
1 parent 088c310 commit 448179d

3 files changed

Lines changed: 68 additions & 23 deletions

File tree

lib/html.js

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,34 +23,53 @@ const firstLine = (value) => (value ? String(value).split('\n').find((l) => l.tr
2323

2424
const TITLE_SEP = ' - '
2525
const capitalize = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s)
26+
const join = (...parts) => parts.filter(Boolean).join(TITLE_SEP)
2627

27-
// Build the page title and description, modelled on 4chan's:
28-
// title: "/tv/ - Television & Film - <subject> - 5chan"
28+
// Split a directory title "/tv/ - Television & Film" into its code ("/tv/") and
29+
// name ("Television & Film") halves.
30+
const splitDirectoryTitle = (title) => {
31+
const i = title.indexOf(TITLE_SEP)
32+
return i === -1 ? { code: title, name: null } : { code: title.slice(0, i), name: title.slice(i + TITLE_SEP.length) }
33+
}
34+
35+
// Build the page title and description, modelled on 4chan's. For a thread the
36+
// title leads with the directory code + post subject, then the board name, so
37+
// the reader sees what they care about (which board + the post) first:
38+
// title: "/tv/ - <subject> - Television & Film - 5chan"
2939
// 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 }) => {
40+
// `boardTitle` is the nice directory title (or null); `label` is the general
41+
// display label (boardTitle, else the community address); both can be null.
42+
const buildMeta = ({ kind, comment, boardTitle, label, siteName, tagline }) => {
3343
const onSite = tagline ? `${siteName}, ${tagline}` : siteName
3444
const context = label ? `"${label}" on ${onSite}` : onSite // board clause for descriptions
35-
const boardPart = label ? `${label}${TITLE_SEP}` : ''
3645

3746
if (kind === 'thread' && comment) {
38-
const subject = truncate(comment.title || firstLine(comment.content), 65)
39-
const body = truncate(comment.content || comment.title, 120)
47+
// Display title: post subject, else a content excerpt, else the link URL — capped at 50.
48+
const subject = truncate(comment.title || firstLine(comment.content) || comment.link, 50)
49+
const body = truncate(comment.content || comment.title || comment.link, 120)
50+
let title
51+
if (boardTitle && subject) {
52+
const { code, name } = splitDirectoryTitle(boardTitle)
53+
title = join(code, subject, name, siteName) // "/tv/ - <subject> - Television & Film - 5chan"
54+
} else if (subject) {
55+
title = join(label, subject, siteName)
56+
} else {
57+
title = label ? join(label, siteName) : siteName
58+
}
4059
return {
41-
title: subject ? `${boardPart}${subject}${TITLE_SEP}${siteName}` : `${boardPart}${siteName}`,
60+
title,
4261
description: body ? `${body}${context}.` : `${capitalize(context)}.`,
4362
}
4463
}
4564
if (kind === 'catalog') {
4665
return {
47-
title: `${boardPart}Catalog${TITLE_SEP}${siteName}`,
66+
title: join(label, 'Catalog', siteName),
4867
description: label ? `Browse the "${label}" catalog on ${onSite}.` : `Browse ${onSite}.`,
4968
}
5069
}
5170
// board home / fallback
5271
return {
53-
title: label ? `${label}${TITLE_SEP}${siteName}` : siteName,
72+
title: label ? join(label, siteName) : siteName,
5473
description: `${capitalize(context)}.`,
5574
}
5675
}
@@ -62,7 +81,7 @@ export const buildPreviewHtml = ({ client, appUrl, comment, board, boardTitle, i
6281
// Board display label: nice directory title > community address > /board/ > none.
6382
const label = boardTitle || comment?.communityAddress || (board ? `/${board}/` : null)
6483

65-
const { title, description } = buildMeta({ kind, comment, label, siteName, tagline: client.tagline })
84+
const { title, description } = buildMeta({ kind, comment, boardTitle, label, siteName, tagline: client.tagline })
6685

6786
const twitterCard = image ? 'summary_large_image' : 'summary'
6887
const favicon = `${client.appBaseUrl}/favicon.ico`

start.js

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ app.disable('x-powered-by')
1616
// Paths that browsers/PWAs request but that are never share links.
1717
const IGNORE = new Set(['/favicon.ico', '/service-worker.js', '/manifest.json', '/apple-touch-icon.png'])
1818

19-
// The comment content is immutable, but the rendered preview (title/tags) can
20-
// change when the format evolves — so cache modestly, never "immutable", so
21-
// updates propagate. Repeat renders are cheap anyway (comments are cached).
22-
const THREAD_CACHE = 'public, max-age=3600'
23-
const FIVE_MIN = 'public, max-age=300'
19+
// Always revalidate (cheap 304s via ETag) so preview/format changes show up
20+
// without cache-busting — the comment is immutable, but the rendered preview is
21+
// not. Repeat renders are cheap (comments are cached in-memory).
22+
const PREVIEW_CACHE = 'no-cache'
2423

2524
const send = (res, html, cacheControl) => {
2625
res.setHeader('Cache-Control', cacheControl)
@@ -41,7 +40,7 @@ app.get('*', async (req, res) => {
4140

4241
// Unknown path -> send to the app home with a generic card (never dead-end).
4342
if (!matched) {
44-
return send(res, buildPreviewHtml({ client, appUrl: client.appBaseUrl }), FIVE_MIN)
43+
return send(res, buildPreviewHtml({ client, appUrl: client.appBaseUrl }), PREVIEW_CACHE)
4544
}
4645

4746
const { route, m } = matched
@@ -56,17 +55,17 @@ app.get('*', async (req, res) => {
5655
const comment = await getComment(cid)
5756
let image = commentMediaUrl(comment)
5857
if (!image && comment.link) image = await scrapeLinkImage(comment.link)
59-
return send(res, buildPreviewHtml({ client, appUrl, comment, board, boardTitle, image, kind: 'thread' }), THREAD_CACHE)
58+
return send(res, buildPreviewHtml({ client, appUrl, comment, board, boardTitle, image, kind: 'thread' }), PREVIEW_CACHE)
6059
} catch (e) {
6160
// Graceful fallback: still redirect to the app with a generic card, but
6261
// with a short TTL so the rich preview can appear once the cid resolves.
6362
debug('getComment failed for', cid, '-', e?.message || e)
64-
return send(res, buildPreviewHtml({ client, appUrl, board, boardTitle, kind: 'thread' }), FIVE_MIN)
63+
return send(res, buildPreviewHtml({ client, appUrl, board, boardTitle, kind: 'thread' }), PREVIEW_CACHE)
6564
}
6665
}
6766

6867
// page (board home or catalog) -> generic card
69-
return send(res, buildPreviewHtml({ client, appUrl, board, boardTitle, kind: route.pageKind || 'board' }), FIVE_MIN)
68+
return send(res, buildPreviewHtml({ client, appUrl, board, boardTitle, kind: route.pageKind || 'board' }), PREVIEW_CACHE)
7069
})
7170

7271
app

test/clients.test.js

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,8 @@ test('5chan thread builds a 4chan-style title + description with the board name'
9393
kind: 'thread',
9494
})
9595
assert.ok(
96-
html.includes('og:title" content="/tv/ - Television &amp; Film - Disclosure Day - 5chan"'),
97-
'title = board name + subject + site',
96+
html.includes('og:title" content="/tv/ - Disclosure Day - Television &amp; Film - 5chan"'),
97+
'title = code + subject + board name + site (4chan order)',
9898
)
9999
assert.ok(
100100
html.includes(
@@ -128,6 +128,33 @@ test('catalog page uses the directory name', () => {
128128
assert.ok(html.includes('og:title" content="/tv/ - Television &amp; Film - Catalog - 5chan"'), 'catalog title')
129129
})
130130

131+
test('thread display title: 50-char content excerpt when there is no subject', () => {
132+
const html = buildPreviewHtml({
133+
client: { siteName: '5chan', appBaseUrl: 'https://5chan.app', tagline: 't' },
134+
appUrl: 'https://5chan.app/#/tv/thread/Q',
135+
comment: { content: 'y'.repeat(80), communityAddress: 'tv.bso' },
136+
board: 'tv',
137+
boardTitle: '/tv/ - Television & Film',
138+
kind: 'thread',
139+
})
140+
const ogTitle = html.match(/og:title" content="([^"]*)"/)[1]
141+
assert.equal(ogTitle, '/tv/ - ' + 'y'.repeat(49) + '… - Television &amp; Film - 5chan')
142+
})
143+
144+
test('thread display title: link fallback when no subject or content', () => {
145+
const link = 'https://i.imgur.com/abcdefgh.png'
146+
const html = buildPreviewHtml({
147+
client: { siteName: '5chan', appBaseUrl: 'https://5chan.app', tagline: 't' },
148+
appUrl: 'https://5chan.app/#/tv/thread/Q',
149+
comment: { link, communityAddress: 'tv.bso' },
150+
board: 'tv',
151+
boardTitle: '/tv/ - Television & Film',
152+
kind: 'thread',
153+
})
154+
const ogTitle = html.match(/og:title" content="([^"]*)"/)[1]
155+
assert.equal(ogTitle, '/tv/ - ' + link + ' - Television &amp; Film - 5chan')
156+
})
157+
131158
test('redirect script cannot break out of the <script> tag', () => {
132159
const html = buildPreviewHtml({
133160
client: { siteName: '5chan', appBaseUrl: 'https://5chan.app' },

0 commit comments

Comments
 (0)