Skip to content

Commit ba6576b

Browse files
committed
Add discourse comment feature
1 parent a5d9c4f commit ba6576b

File tree

3 files changed

+287
-20
lines changed

3 files changed

+287
-20
lines changed

docusaurus.config.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ const config = {
105105
SENTRY_KEY: process.env.SENTRY_KEY,
106106
LINEA_ENS_URL: process.env.LINEA_ENS_URL,
107107
SEGMENT_ANALYTICS_KEY: process.env.SEGMENT_ANALYTICS_KEY,
108+
// Discourse Integration for automatic tutorial topic creation
109+
// Set DISCOURSE_API_KEY from your Discourse admin panel (API > Keys)
110+
// Set DISCOURSE_API_USERNAME (usually 'system')
111+
// Set DISCOURSE_CATEGORY_ID (find in Discourse admin, default: 6 for tutorials)
112+
DISCOURSE_API_KEY: process.env.DISCOURSE_API_KEY,
113+
DISCOURSE_API_USERNAME: process.env.DISCOURSE_API_USERNAME,
114+
DISCOURSE_CATEGORY_ID: process.env.DISCOURSE_CATEGORY_ID,
108115
},
109116

110117
trailingSlash: true,

src/components/DiscourseComment/index.jsx

Lines changed: 273 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,286 @@
1-
import { useEffect } from 'react'
1+
import { useEffect, useState } from 'react'
2+
import useDocusaurusContext from '@docusaurus/useDocusaurusContext'
3+
4+
const DISCOURSE_URL = 'https://builder.metamask.io'
25

36
export default function DiscourseComment(props) {
47
// eslint-disable-next-line react/prop-types
5-
const { postUrl } = props
8+
const { postUrl, discourseTopicId, metadata = {} } = props
9+
const { siteConfig } = useDocusaurusContext()
10+
const { customFields } = siteConfig
11+
12+
const DISCOURSE_API_KEY = customFields.DISCOURSE_API_KEY
13+
const DISCOURSE_API_USERNAME = customFields.DISCOURSE_API_USERNAME || 'system'
14+
const DISCOURSE_CATEGORY_ID = customFields.DISCOURSE_CATEGORY_ID || '6'
15+
16+
const [loading, setLoading] = useState(false)
17+
const [error, setError] = useState(null)
18+
const [topicId, setTopicId] = useState(discourseTopicId)
19+
20+
// Extract content from page for topic creation
21+
const extractContentExcerpt = () => {
22+
try {
23+
// Find main content area
24+
const contentSelector = 'article .markdown, main .markdown, .theme-doc-markdown'
25+
const contentElement = document.querySelector(contentSelector)
26+
27+
if (!contentElement) {
28+
return metadata.description || ''
29+
}
30+
31+
// Get text from first few paragraphs, skip imports and components
32+
const paragraphs = contentElement.querySelectorAll('p')
33+
let excerpt = ''
34+
let wordCount = 0
35+
36+
for (const p of paragraphs) {
37+
const text = p.textContent.trim()
38+
39+
// Skip imports, empty paragraphs, and components
40+
if (!text || text.startsWith('import ') || text.includes('</') || text.includes('>{')) {
41+
continue
42+
}
43+
44+
const words = text.split(' ')
45+
if (wordCount + words.length <= 200) {
46+
excerpt += (excerpt ? '\n\n' : '') + text
47+
wordCount += words.length
48+
} else {
49+
const remainingWords = 200 - wordCount
50+
if (remainingWords > 10) {
51+
excerpt += (excerpt ? '\n\n' : '') + words.slice(0, remainingWords).join(' ') + '...'
52+
}
53+
break
54+
}
55+
}
56+
57+
return excerpt || metadata.description || ''
58+
} catch (e) {
59+
console.warn('Failed to extract content:', e)
60+
return metadata.description || ''
61+
}
62+
}
63+
64+
// Format post content for Discourse
65+
const formatDiscourseContent = excerpt => {
66+
const { title, image, description, tags = [], author, date } = metadata
67+
const imageUrl = image?.startsWith('http') ? image : `https://docs.metamask.io/${image || ''}`
68+
69+
let content = ''
70+
71+
// Add banner image if available
72+
if (image) {
73+
content += `![${title}](${imageUrl})\n\n`
74+
}
75+
76+
// Add description
77+
if (description) {
78+
content += `${description}\n\n`
79+
}
80+
81+
// Add metadata
82+
const metaInfo = []
83+
if (date) metaInfo.push(`**Published:** ${date}`)
84+
if (author) metaInfo.push(`**Author:** ${author}`)
85+
if (tags.length > 0) metaInfo.push(`**Tags:** ${tags.join(', ')}`)
86+
87+
if (metaInfo.length > 0) {
88+
content += `${metaInfo.join(' | ')}\n\n---\n\n`
89+
}
90+
91+
// Add excerpt
92+
if (excerpt) {
93+
content += `${excerpt}\n\n---\n\n`
94+
}
95+
96+
// Add link back to docs
97+
content += `📚 **[Continue reading the full tutorial →](${postUrl})**`
98+
99+
return content
100+
}
101+
102+
// Search for existing topic by URL using Discourse search API
103+
const searchExistingTopic = async url => {
104+
try {
105+
// Use Discourse search API to find topics with matching embed_url
106+
const searchQuery = encodeURIComponent(url)
107+
const response = await fetch(`${DISCOURSE_URL}/search.json?q=${searchQuery}`, {
108+
method: 'GET',
109+
headers: {
110+
'Api-Key': DISCOURSE_API_KEY,
111+
'Api-Username': DISCOURSE_API_USERNAME,
112+
'Content-Type': 'application/json',
113+
},
114+
mode: 'cors',
115+
})
116+
117+
if (response.ok) {
118+
const data = await response.json()
119+
// Look for topics that might match our URL
120+
const matchingTopic = data.topics?.find(
121+
topic => topic.title?.includes(metadata.title) || topic.excerpt?.includes(url)
122+
)
123+
return matchingTopic?.id || null
124+
}
125+
return null
126+
} catch (e) {
127+
console.warn('Failed to search for existing topic:', e)
128+
return null
129+
}
130+
}
131+
132+
// Create new Discourse topic
133+
const createDiscourseTopic = async () => {
134+
if (!DISCOURSE_API_KEY) {
135+
throw new Error('Discourse API key not configured')
136+
}
137+
138+
const excerpt = extractContentExcerpt()
139+
const content = formatDiscourseContent(excerpt)
140+
141+
const response = await fetch(`${DISCOURSE_URL}/posts.json`, {
142+
method: 'POST',
143+
headers: {
144+
'Api-Key': DISCOURSE_API_KEY,
145+
'Api-Username': DISCOURSE_API_USERNAME,
146+
'Content-Type': 'application/json',
147+
},
148+
mode: 'cors',
149+
body: JSON.stringify({
150+
title: metadata.title || 'Tutorial Discussion',
151+
raw: content,
152+
category: DISCOURSE_CATEGORY_ID,
153+
tags: metadata.tags || [],
154+
embed_url: postUrl,
155+
}),
156+
})
157+
158+
if (!response.ok) {
159+
const errorData = await response.json().catch(() => ({}))
160+
throw new Error(errorData.errors?.[0] || `HTTP ${response.status}`)
161+
}
162+
163+
const data = await response.json()
164+
return data.topic_id
165+
}
166+
167+
// Load or create Discourse embed
168+
const loadDiscourseEmbed = finalTopicId => {
169+
// Clean up any existing embed
170+
const existingScript = document.querySelector('script[src*="embed.js"]')
171+
if (existingScript) {
172+
existingScript.remove()
173+
}
174+
175+
const existingComments = document.getElementById('discourse-comments')
176+
if (existingComments) {
177+
existingComments.innerHTML = ''
178+
}
179+
180+
// Set up Discourse embed configuration
181+
window.DiscourseEmbed = {
182+
discourseUrl: `${DISCOURSE_URL}/`,
183+
discourseEmbedUrl: postUrl,
184+
...(finalTopicId && { topicId: finalTopicId }),
185+
}
186+
187+
// Load embed script
188+
const script = document.createElement('script')
189+
script.type = 'text/javascript'
190+
script.async = true
191+
script.src = `${DISCOURSE_URL}/javascripts/embed.js`
192+
193+
const targetElement =
194+
document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]
195+
targetElement.appendChild(script)
196+
}
197+
198+
// Main effect to handle topic creation/loading
6199
useEffect(() => {
7-
const url = window.location.href
8-
if (!url.includes('https://metamask.io/')) {
200+
if (topicId) {
201+
// Topic ID provided in frontmatter, use it directly
202+
loadDiscourseEmbed(topicId)
9203
return
10-
} else {
11-
window.DiscourseEmbed = {
12-
discourseUrl: 'https://builder.metamask.io/',
13-
discourseEmbedUrl: postUrl,
14-
}
204+
}
205+
206+
// No topic ID, need to search or create
207+
const handleTopicCreation = async () => {
208+
setLoading(true)
209+
setError(null)
210+
211+
try {
212+
// Check if API key is configured
213+
if (!DISCOURSE_API_KEY) {
214+
console.log('No Discourse API key configured, using basic embed')
215+
loadDiscourseEmbed()
216+
return
217+
}
15218

16-
const d = document.createElement('script')
17-
d.type = 'text/javascript'
18-
d.async = true
19-
d.src = `${window.DiscourseEmbed.discourseUrl}javascripts/embed.js`
20-
;(
21-
document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]
22-
).appendChild(d)
219+
console.log('Searching for existing topic...')
220+
// First, search for existing topic
221+
let foundTopicId = await searchExistingTopic(postUrl)
222+
223+
if (!foundTopicId) {
224+
console.log('No existing topic found, creating new one...')
225+
// No existing topic found, create new one
226+
foundTopicId = await createDiscourseTopic()
227+
console.log('Created topic with ID:', foundTopicId)
228+
} else {
229+
console.log('Found existing topic with ID:', foundTopicId)
230+
}
231+
232+
setTopicId(foundTopicId)
233+
loadDiscourseEmbed(foundTopicId)
234+
} catch (e) {
235+
console.error('Discourse integration error:', e)
236+
237+
// Check if it's a CORS or authentication error
238+
if (e.message.includes('CORS') || e.message.includes('Failed to fetch')) {
239+
setError('CORS configuration needed - check Discourse admin settings')
240+
} else {
241+
setError(e.message)
242+
}
243+
244+
// Fallback to basic embed without topic creation
245+
console.log('Falling back to basic embed...')
246+
loadDiscourseEmbed()
247+
} finally {
248+
setLoading(false)
249+
}
23250
}
24-
}, [])
251+
252+
// Add small delay to ensure DOM is ready
253+
const timer = setTimeout(handleTopicCreation, 100)
254+
return () => clearTimeout(timer)
255+
}, [postUrl, topicId])
256+
257+
if (error && !DISCOURSE_API_KEY) {
258+
return (
259+
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
260+
<p>💬 Discussion integration not configured.</p>
261+
<p style={{ fontSize: '0.9em' }}>
262+
To enable automatic topic creation, configure Discourse API credentials.
263+
</p>
264+
</div>
265+
)
266+
}
25267

26268
return (
27269
<>
28-
<meta name="discourse-username" content="shahbaz"></meta>
270+
<meta name="discourse-username" content="system" />
271+
{loading && (
272+
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
273+
<p>🔄 Loading discussion...</p>
274+
</div>
275+
)}
276+
{error && (
277+
<div style={{ padding: '20px', textAlign: 'center', color: '#e74c3c' }}>
278+
<p>⚠️ Failed to load discussion: {error}</p>
279+
<p style={{ fontSize: '0.9em', color: '#666' }}>
280+
Comments may still appear below if the topic exists.
281+
</p>
282+
</div>
283+
)}
29284
<div id="discourse-comments" />
30285
</>
31286
)

src/theme/MDXPage/index.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export default function MDXPage(props: ComponentProps<typeof OriginalMDXPage>) {
3737
date,
3838
wrapperClassName,
3939
communityPortalTopicId,
40+
discourse_topic_id,
4041
} = frontMatter
4142
const url = `https://metamask.io${permalink}`
4243
const facebookLink = `https://www.facebook.com/sharer/sharer.php?${url}`
@@ -59,7 +60,7 @@ export default function MDXPage(props: ComponentProps<typeof OriginalMDXPage>) {
5960
<div className="row">
6061
<div className="col col--8 col--offset-1">
6162
<div className={styles.titleContainer}>
62-
{/* <img className={styles.cover} src={baseUrl + image} alt="Cover" /> */}
63+
<img className={styles.cover} src={"https://docs.metamask.io/" + image} alt="Cover" />
6364
<div className={styles.titleContainer}>
6465
<h1 className={styles.title}>{title}</h1>
6566
<div className={styles.topMenu}>
@@ -163,7 +164,11 @@ export default function MDXPage(props: ComponentProps<typeof OriginalMDXPage>) {
163164
)}
164165
</BrowserOnly>
165166
</div>
166-
<DiscourseComment postUrl={url} />
167+
<DiscourseComment
168+
postUrl={url}
169+
discourseTopicId={discourse_topic_id}
170+
metadata={{ title, image, description, tags, author, date }}
171+
/>
167172
</div>
168173
{MDXPageContent.toc && (
169174
<div className="col col--3" style={{ paddingRight: '30px' }}>

0 commit comments

Comments
 (0)