Compare commits

..

8 Commits

8 changed files with 172 additions and 11 deletions

View File

@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.22] - 2025-10-16
### Added
- Dynamic OpenGraph and Twitter Card meta tags for article deep-links
- Social media platforms display article title, author, cover image, and summary when sharing `/a/{naddr}` links
- Serverless endpoint fetches article metadata from Nostr relays (kind:30023) and author profiles (kind:0)
- User-agent detection serves appropriate content to crawlers vs browsers
- Falls back to default social preview image when articles have no cover image
- Social preview image for homepage and article links
- Added `boris-social-1200.png` as default OpenGraph image (1200x630)
- Homepage now includes social preview image in meta tags
### Changed
- Article deep-links now properly preserve URL when loading in browser
- Uses `history.replaceState()` to maintain correct article path
- Browser navigation works correctly on refresh and new tab opens
### Fixed
- Vercel rewrite configuration for article routes
- Routes `/a/:naddr` to serverless OG endpoint for dynamic meta tags
- Regular SPA routing preserved for browser navigation
## [0.6.21] - 2025-10-16
### Added

View File

@@ -58,15 +58,14 @@ async function fetchEventsFromRelays(
timeoutMs: number
): Promise<NostrEvent[]> {
const events: NostrEvent[] = []
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), timeoutMs)
relayPool.req(relayUrls, filter).subscribe({
next: (msg) => {
if (msg.type === 'EVENT') {
events.push(msg.event)
}
// `request` emits NostrEvent objects directly
relayPool.request(relayUrls, filter).subscribe({
next: (event) => {
events.push(event)
},
error: () => resolve(),
complete: () => {
@@ -92,9 +91,8 @@ async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | nu
const pointer = decoded.data as AddressPointer
// Connect to relays
// Determine relay URLs
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
relayUrls.forEach(url => relayPool.open(url))
// Fetch article and profile in parallel
const [articleEvents, profileEvents] = await Promise.all([
@@ -142,7 +140,7 @@ async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | nu
console.error('Failed to fetch article metadata:', err)
return null
} finally {
relayPool.close()
// No explicit close needed; pool manages connections internally
}
}
@@ -215,6 +213,17 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
const userAgent = req.headers['user-agent'] as string | undefined
const isCrawlerRequest = isCrawler(userAgent)
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
if (debugEnabled) {
console.log('[article-og] request', JSON.stringify({
naddr,
ua: userAgent || null,
isCrawlerRequest,
path: req.url || null
}))
res.setHeader('X-Boris-Debug', '1')
}
// If it's a regular browser (not a bot), serve HTML that loads SPA
// Use history.replaceState to set the URL before the SPA boots
if (!isCrawlerRequest) {
@@ -233,6 +242,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
history.replaceState(null, '', '${articlePath}');
}
</script>
${debugEnabled ? `<script>console.debug('article-og', { mode: 'browser', naddr: '${naddr}', path: location.pathname, referrer: document.referrer });</script>` : ''}
<script>
// Redirect to index.html which will load the SPA
// The history state is already set, so SPA will see the correct URL
@@ -246,6 +256,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'browser', naddr }))
}
return res.status(200).send(html)
}
@@ -254,6 +267,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
const cached = memoryCache.get(naddr)
if (cached && cached.expires > now) {
setCacheHeaders(res)
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: true }))
}
return res.status(200).send(cached.html)
}
@@ -269,6 +285,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
// Send response
setCacheHeaders(res)
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: false }))
}
return res.status(200).send(html)
} catch (err) {
console.error('Error generating article OG HTML:', err)
@@ -276,6 +295,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
// Fallback to basic HTML with SPA boot
const html = generateHtml(naddr, null)
setCacheHeaders(res, 3600)
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'bot-fallback', naddr }))
}
return res.status(200).send(html)
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.6.22",
"version": "0.6.23",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

View File

@@ -9,6 +9,7 @@ import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
import { RelayPool } from 'applesauce-relay'
import { createAddressLoader } from 'applesauce-loaders/loaders'
import Bookmarks from './components/Bookmarks'
import RouteDebug from './components/RouteDebug'
import Toast from './components/Toast'
import { useToast } from './hooks/useToast'
import { useOnlineStatus } from './hooks/useOnlineStatus'
@@ -303,6 +304,7 @@ function App() {
<BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} />
<RouteDebug />
</div>
</BrowserRouter>
{toastMessage && (

View File

@@ -0,0 +1,30 @@
import { useEffect } from 'react'
import { useLocation, useMatch } from 'react-router-dom'
export default function RouteDebug() {
const location = useLocation()
const matchArticle = useMatch('/a/:naddr')
useEffect(() => {
const params = new URLSearchParams(location.search)
if (params.get('debug') !== '1') return
const info: Record<string, unknown> = {
pathname: location.pathname,
search: location.search || null,
matchedArticleRoute: Boolean(matchArticle),
referrer: document.referrer || null
}
if (location.pathname === '/') {
// Unexpected during deep-link refresh tests
console.warn('[RouteDebug] unexpected root redirect', info)
} else {
console.debug('[RouteDebug]', info)
}
}, [location, matchArticle])
return null
}

View File

@@ -167,6 +167,21 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
</div>
<div className="text-xs opacity-60 mt-4 px-4 pb-3 select-text">
<span>Version {typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}</span>
{typeof __GIT_COMMIT__ !== 'undefined' && __GIT_COMMIT__ ? (
<span>
{' '}·
{typeof __GIT_COMMIT_URL__ !== 'undefined' && __GIT_COMMIT_URL__ ? (
<a href={__GIT_COMMIT_URL__} target="_blank" rel="noopener noreferrer">
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
</a>
) : (
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
)}
</span>
) : null}
</div>
</div>
)
}

View File

@@ -2,6 +2,13 @@
"rewrites": [
{
"source": "/a/:naddr",
"has": [
{
"type": "header",
"key": "user-agent",
"value": ".*(bot|crawl|spider|slurp|facebook|twitter|linkedin|whatsapp|telegram|slack|discord|preview).*"
}
],
"destination": "/api/article-og?naddr=:naddr"
},
{

View File

@@ -1,8 +1,68 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
import { readFileSync } from 'node:fs'
import { execSync } from 'node:child_process'
function getGitMetadata() {
const envSha = process.env.VERCEL_GIT_COMMIT_SHA || ''
const envRef = process.env.VERCEL_GIT_COMMIT_REF || ''
let commit = envSha
let branch = envRef
try {
if (!commit) commit = execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
} catch {}
try {
if (!branch) branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
} catch {}
return { commit, branch }
}
function getPackageVersion() {
try {
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)).toString())
return pkg.version as string
} catch {
return '0.0.0'
}
}
const { commit, branch } = getGitMetadata()
const version = getPackageVersion()
const buildTime = new Date().toISOString()
function getCommitUrl(commit: string): string {
if (!commit) return ''
const provider = process.env.VERCEL_GIT_PROVIDER || ''
const owner = process.env.VERCEL_GIT_REPO_OWNER || ''
const slug = process.env.VERCEL_GIT_REPO_SLUG || ''
if (provider.toLowerCase() === 'github' && owner && slug) {
return `https://github.com/${owner}/${slug}/commit/${commit}`
}
try {
const remote = execSync('git config --get remote.origin.url', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
if (remote.includes('github.com')) {
// git@github.com:owner/repo.git or https://github.com/owner/repo.git
const https = remote.startsWith('git@')
? `https://github.com/${remote.split(':')[1]}`
: remote
const cleaned = https.replace(/\.git$/, '')
return `${cleaned}/commit/${commit}`
}
} catch {}
return ''
}
const commitUrl = getCommitUrl(commit)
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(version),
__GIT_COMMIT__: JSON.stringify(commit),
__GIT_BRANCH__: JSON.stringify(branch),
__BUILD_TIME__: JSON.stringify(buildTime),
__GIT_COMMIT_URL__: JSON.stringify(commitUrl)
},
plugins: [
react(),
VitePWA({