Skip to content

Commit 2ee5a78

Browse files
committed
feat: add territory SEO
1 parent b0f01c1 commit 2ee5a78

File tree

4 files changed

+210
-28
lines changed

4 files changed

+210
-28
lines changed

components/layout.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ import PullToRefresh from './pull-to-refresh'
1010

1111
export default function Layout ({
1212
sub, contain = true, footer = true, footerLinks = true,
13-
containClassName = '', seo = true, item, user, children
13+
containClassName = '', seo = true, item, user, territory, children
1414
}) {
1515
return (
1616
<>
17-
{seo && <Seo sub={sub} item={item} user={user} />}
17+
{seo && <Seo sub={sub} item={item} user={user} territory={territory} />}
1818
<Navigation sub={sub} />
1919
{contain
2020
? (

components/seo.js

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { NextSeo } from 'next-seo'
22
import { useRouter } from 'next/router'
3-
import removeMd from 'remove-markdown'
4-
import { numWithUnits } from '@/lib/format'
3+
import { processTerritoryDescription, processItemDescription, processUserDescription } from '@/lib/seo'
54

65
export function SeoSearch ({ sub }) {
76
const router = useRouter()
@@ -36,40 +35,25 @@ export function SeoSearch ({ sub }) {
3635
// index page seo
3736
// recent page seo
3837

39-
export default function Seo ({ sub, item, user }) {
38+
export default function Seo ({ sub, item, user, territory }) {
4039
const router = useRouter()
4140
const pathNoQuery = router.asPath.split('?')[0]
4241
const defaultTitle = pathNoQuery.slice(1)
4342
const snStr = `stacker news${sub ? ` ~${sub}` : ''}`
4443
let fullTitle = `${defaultTitle && `${defaultTitle} \\ `}stacker news`
4544
let desc = "It's like Hacker News but we pay you Bitcoin."
46-
if (item) {
45+
if (territory) {
46+
fullTitle = `${territory.name} \\ ${snStr}`
47+
desc = processTerritoryDescription(territory)
48+
} else if (item) {
4749
if (item.title) {
4850
fullTitle = `${item.title} \\ ${snStr}`
4951
} else if (item.root) {
5052
fullTitle = `reply on: ${item.root.title} \\ ${snStr}`
5153
}
52-
// at least for now subs (ie the only one is jobs) will always have text
53-
if (item.text) {
54-
desc = removeMd(item.text)
55-
if (desc) {
56-
desc = desc.replace(/\s+/g, ' ')
57-
}
58-
} else {
59-
desc = `@${item.user.name} stacked ${numWithUnits(item.sats)} ${item.url ? `posting ${item.url}` : 'with this discussion'}`
60-
}
61-
if (item.ncomments) {
62-
desc += ` [${numWithUnits(item.ncomments, { unitSingular: 'comment', unitPlural: 'comments' })}`
63-
if (item.boost) {
64-
desc += `, ${item.boost} boost`
65-
}
66-
desc += ']'
67-
} else if (item.boost) {
68-
desc += ` [${item.boost} boost]`
69-
}
70-
}
71-
if (user) {
72-
desc = `@${user.name} has [${user.optional.stacked ? `${user.optional.stacked} stacked,` : ''}${numWithUnits(user.nitems, { unitSingular: 'item', unitPlural: 'items' })}]`
54+
desc = processItemDescription(item)
55+
} else if (user) {
56+
desc = processUserDescription(user)
7357
}
7458

7559
return (

lib/seo.js

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import removeMd from 'remove-markdown'
2+
import { numWithUnits } from './format'
3+
4+
export function cleanMarkdownText (text) {
5+
if (!text) return ''
6+
7+
let cleaned = removeMd(text)
8+
cleaned = cleaned
9+
.replace(/\s+/g, ' ') // Replace multiple spaces with single space
10+
// Handle nested markdown links like [[text]](url) - remove outer brackets and keep inner text
11+
.replace(/\[\[([^\]]+)\]\]\([^)]+\)/g, '$1') // Remove nested markdown links, keep inner text
12+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove regular markdown links, keep text
13+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '') // Remove images completely
14+
.replace(/`([^`]+)`/g, '$1') // Remove code formatting
15+
.replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold formatting
16+
.replace(/\*([^*]+)\*/g, '$1') // Remove italic formatting
17+
.trim()
18+
19+
return cleaned
20+
}
21+
22+
export function truncateAtSentenceBoundary (text, maxLength = 160) {
23+
if (!text || text.length <= maxLength) return text
24+
25+
const sentences = text
26+
.split(/[.!?]+/)
27+
.map(s => s.trim())
28+
.filter(Boolean)
29+
30+
let result = ''
31+
let currentLength = 0
32+
33+
for (const sentence of sentences) {
34+
const separator = result ? '. ' : ''
35+
const newLength = currentLength + separator.length + sentence.length
36+
37+
if (newLength <= maxLength - 3) {
38+
result += separator + sentence
39+
currentLength = newLength
40+
} else {
41+
break
42+
}
43+
}
44+
45+
if (result) {
46+
return result + '...'
47+
}
48+
49+
// Fallback to simple truncation
50+
return text.substring(0, maxLength - 3) + '...'
51+
}
52+
53+
export function processDescription (text, options = {}) {
54+
const { minLength = 30, maxLength = 160, fallback = null } = options
55+
56+
if (!text) return fallback
57+
58+
const processed = cleanMarkdownText(text)
59+
60+
if (processed.length >= minLength && processed.length <= maxLength) {
61+
return processed
62+
}
63+
64+
if (processed.length > maxLength) {
65+
return truncateAtSentenceBoundary(processed, maxLength)
66+
}
67+
68+
return fallback
69+
}
70+
71+
export function processItemSEODescription (item) {
72+
if (item.text) {
73+
const processed = processDescription(item.text, {
74+
minLength: 30,
75+
maxLength: 160,
76+
fallback: generateItemDescription(item)
77+
})
78+
79+
if (processed) return processed
80+
}
81+
82+
return generateItemDescription(item)
83+
}
84+
85+
function generateItemDescription (item) {
86+
const parts = []
87+
88+
if (item.user?.name) {
89+
parts.push(`@${item.user.name}`)
90+
}
91+
92+
if (item.sats > 0) {
93+
parts.push(`stacked ${numWithUnits(item.sats)}`)
94+
}
95+
96+
if (item.url) {
97+
parts.push(`posting ${item.url}`)
98+
} else {
99+
parts.push('with this discussion')
100+
}
101+
102+
if (item.ncomments) {
103+
parts.push(`${numWithUnits(item.ncomments, { unitSingular: 'comment', unitPlural: 'comments' })})`)
104+
}
105+
106+
if (item.boost) {
107+
parts.push(`${item.boost} boost`)
108+
}
109+
110+
return parts.join(' ')
111+
}
112+
113+
export function processUserSEODescription (user) {
114+
if (user.bio?.text) {
115+
const processed = processDescription(user.bio.text, {
116+
minLength: 30,
117+
maxLength: 160,
118+
fallback: generateUserDescription(user)
119+
})
120+
121+
if (processed) return processed
122+
}
123+
124+
return generateUserDescription(user)
125+
}
126+
127+
function generateUserDescription (user) {
128+
const parts = []
129+
130+
if (user.optional?.stacked) {
131+
parts.push(`${user.optional.stacked} stacked`)
132+
}
133+
134+
if (user.nitems) {
135+
parts.push(`${numWithUnits(user.nitems, { unitSingular: 'item', unitPlural: 'items' })})`)
136+
}
137+
138+
if (parts.length > 0) {
139+
return `@${user.name} has [${parts.join(', ')}]`
140+
}
141+
142+
return `@${user.name}`
143+
}
144+
145+
export function processTerritorySEODescription (territory) {
146+
if (!territory) return null
147+
148+
if (territory.desc) {
149+
const processedDesc = cleanMarkdownText(territory.desc)
150+
151+
if (processedDesc.length >= 30 && processedDesc.length <= 160) {
152+
return processedDesc
153+
}
154+
if (processedDesc.length > 160) {
155+
return truncateAtSentenceBoundary(processedDesc, 160)
156+
}
157+
}
158+
159+
// Generate a contextual description if the original is too short or unsuitable
160+
return generateTerritoryDescription(territory)
161+
}
162+
163+
function generateTerritoryDescription (territory) {
164+
const parts = []
165+
if (territory.desc) {
166+
const processedDesc = cleanMarkdownText(territory.desc)
167+
if (processedDesc) {
168+
parts.push(processedDesc)
169+
}
170+
}
171+
172+
if (territory.postTypes && territory.postTypes.length > 0) {
173+
const postTypeLabels = territory.postTypes.map(type => {
174+
switch (type) {
175+
case 'LINK': return 'links'
176+
case 'DISCUSSION': return 'discussions'
177+
case 'BOUNTY': return 'bounties'
178+
case 'POLL': return 'polls'
179+
default: return type.toLowerCase()
180+
}
181+
})
182+
parts.push(postTypeLabels.join(', '))
183+
}
184+
185+
if (territory.user?.name) {
186+
parts.push(`created by @${territory.user.name}`)
187+
}
188+
189+
let desc = parts.length > 1
190+
? `Stacker.news community: ${parts.join('. ')}`
191+
: `Stacker.news community for ${parts.join(', ')}`
192+
193+
if (desc.length > 160) {
194+
desc = truncateAtSentenceBoundary(desc, 160)
195+
}
196+
197+
return desc
198+
}

pages/~/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export default function Sub ({ ssrData }) {
2222
const { sub } = data || ssrData
2323

2424
return (
25-
<Layout sub={sub?.name}>
25+
<Layout sub={sub?.name} territory={sub}>
2626
{sub
2727
? <TerritoryHeader sub={sub} />
2828
: (

0 commit comments

Comments
 (0)