mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ec89458c | ||
|
|
d8849b2d81 | ||
|
|
a431bbea6c | ||
|
|
3cbad434d6 | ||
|
|
4d3047476d | ||
|
|
bf81cd51b7 | ||
|
|
d50276adca | ||
|
|
785be6aa9e | ||
|
|
934bee2d62 | ||
|
|
00eb9ae55b | ||
|
|
61968c8892 | ||
|
|
bd0dcbb7f2 | ||
|
|
645e1f2b18 | ||
|
|
02de0e7011 | ||
|
|
e491f7e385 | ||
|
|
62e5b2b0af | ||
|
|
be03b9c9cc | ||
|
|
3da6a70f77 | ||
|
|
a2dc928681 | ||
|
|
1f88201c18 | ||
|
|
85e93b69aa | ||
|
|
5cede24650 | ||
|
|
2348361d1d | ||
|
|
c134c3db57 | ||
|
|
18dbc521ee | ||
|
|
8600c09344 | ||
|
|
efb6b56c3b | ||
|
|
cc22524466 | ||
|
|
bca1ee2b2e | ||
|
|
4d18c84243 | ||
|
|
c1b171d188 | ||
|
|
fdb22491a2 | ||
|
|
ff2cb41a3c | ||
|
|
5a5cfb7edd | ||
|
|
63a820faf8 | ||
|
|
0bfa0a2e7b | ||
|
|
6445445e5d | ||
|
|
85d256b47b | ||
|
|
55d14d9e77 | ||
|
|
f41cb4b17e | ||
|
|
286d5df5b8 | ||
|
|
36659ad2cc | ||
|
|
ee7e88bc62 | ||
|
|
120409dc7b | ||
|
|
b2aa9c4179 | ||
|
|
0dc9e37ff4 | ||
|
|
5181176260 | ||
|
|
3b4f3e8161 | ||
|
|
2323427dbd | ||
|
|
43e6455668 | ||
|
|
7b3f36b0bb | ||
|
|
feafe4a07b | ||
|
|
ed1a4e489e | ||
|
|
4ab34456d1 | ||
|
|
54ed0c547f | ||
|
|
98291f0904 | ||
|
|
f0b3ad239c | ||
|
|
7d7e60c226 | ||
|
|
55ea43e103 | ||
|
|
631d65be21 | ||
|
|
76b9797c41 | ||
|
|
4fc4971345 | ||
|
|
f2bc0c1da1 | ||
|
|
f486de1597 | ||
|
|
b0e43ccee7 | ||
|
|
66db9cd23f | ||
|
|
c2552d2e34 | ||
|
|
56547b3526 | ||
|
|
70ac7dce95 | ||
|
|
f982781dd8 | ||
|
|
a73c7db9d3 | ||
|
|
c81b7b89d1 | ||
|
|
971b672591 | ||
|
|
8b30ffd5e7 | ||
|
|
3975ef15dd | ||
|
|
61e8517137 | ||
|
|
b0d30946eb | ||
|
|
c0cfd41e76 | ||
|
|
be7b6c2cfb | ||
|
|
afd27032e0 | ||
|
|
696fe42bee | ||
|
|
1a0370aef9 | ||
|
|
ed3e8e9799 | ||
|
|
f590ff56ec | ||
|
|
cc68980cdb | ||
|
|
d83708ceb3 | ||
|
|
507aa27d29 | ||
|
|
1d4c5a7393 | ||
|
|
64fd2cc0d3 | ||
|
|
b6182b3c11 | ||
|
|
e7e02dd129 | ||
|
|
d76bfb66bb | ||
|
|
024e62118b | ||
|
|
ed93675d8d | ||
|
|
2089208448 | ||
|
|
4fd8a0b18f | ||
|
|
48213fa584 | ||
|
|
eaabad98c2 | ||
|
|
31bcd61aae | ||
|
|
f6c00f4c20 | ||
|
|
0ce9f76f3b | ||
|
|
781cade78b | ||
|
|
15e91414da | ||
|
|
453a4f48ca | ||
|
|
a91aa87ef9 | ||
|
|
52be65e382 | ||
|
|
142995e83c | ||
|
|
03a7f91961 | ||
|
|
496b329e82 | ||
|
|
a4c8a7d68b | ||
|
|
8f90de01fd | ||
|
|
341fbd8c2a | ||
|
|
01722cff38 | ||
|
|
a7a7857219 |
2
.env
2
.env
@@ -1,2 +0,0 @@
|
|||||||
# Default article to display on app load
|
|
||||||
VITE_DEFAULT_ARTICLE_NADDR=naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew
|
|
||||||
17
.env.example
17
.env.example
@@ -1,3 +1,14 @@
|
|||||||
# Default article to display on app load
|
# Nostr configuration for publish-markdown.sh script
|
||||||
# This should be a valid naddr1... string (NIP-19 encoded address pointer to a kind:30023 long-form article)
|
# Copy this file to .env and fill in your values
|
||||||
VITE_DEFAULT_ARTICLE_NADDR=naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew
|
|
||||||
|
# Your Nostr secret key (nsec, ncryptsec, or hex format)
|
||||||
|
# You can also set this via environment variable: export NOSTR_SECRET_KEY=your_key
|
||||||
|
NOSTR_SECRET_KEY=
|
||||||
|
|
||||||
|
# Space-separated list of relay URLs to publish to
|
||||||
|
# If not provided, events will be created but not published
|
||||||
|
RELAYS="ws://localhost:10547 ws://localhost:4869 wss://relay.primal.net wss://wot.dergigi.com wss://relay.dergigi.com wss://nostr.einundzwanzig.space wss://relay.damus.io wss://relay.nostr.bg wss://nos.lol wss://eden.nostr.land"
|
||||||
|
|
||||||
|
# Test account used for publishing markdown test documents:
|
||||||
|
# npub: npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2
|
||||||
|
# Profile: https://read.withboris.com/p/npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2/writings
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,3 +13,6 @@ applesauce
|
|||||||
primal-web-app
|
primal-web-app
|
||||||
Amber
|
Amber
|
||||||
|
|
||||||
|
.env
|
||||||
|
scripts/.env
|
||||||
|
.vercel
|
||||||
|
|||||||
103
CHANGELOG.md
103
CHANGELOG.md
@@ -7,6 +7,109 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.11.1] - 2025-11-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Three-dot menu to profile view
|
||||||
|
- Clickable quote text in highlights to navigate to article
|
||||||
|
- Profile navigation from highlight author cards
|
||||||
|
- Improved relay hint selection to exclude non-content relays
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Profile header horizontal padding matches tabs width
|
||||||
|
- Profile menu positioning inside card
|
||||||
|
- Highlight quote button navigation reliability
|
||||||
|
- Highlight menu cutoff when only one highlight
|
||||||
|
- Article loading reuses Explore article events for immediate display
|
||||||
|
- Removed unused variables and imports
|
||||||
|
|
||||||
|
### Refactored
|
||||||
|
|
||||||
|
- Unified relay configuration with typed registry
|
||||||
|
- Improved relay hint selection and relay management
|
||||||
|
|
||||||
|
## [0.11.0] - 2025-11-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Configurable link color setting for article links
|
||||||
|
- Basic markdown syntax test files for testing
|
||||||
|
- Article tags and image alt text to OpenGraph metadata
|
||||||
|
- Storage-backed OpenGraph previews with Upstash Redis
|
||||||
|
- Always render OpenGraph meta for `/a/:naddr` routes with redirect script for browsers
|
||||||
|
- Script to publish markdown test files to Nostr using nak
|
||||||
|
- npm script for publishing test markdown files
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Default link color changed to Sky Blue (#38bdf8)
|
||||||
|
- Link color setting renamed to `--color-link` with dark/light theme support
|
||||||
|
- Use single link color setting with theme-aware palette
|
||||||
|
- Increased paragraph spacing in reader view to 1.5rem
|
||||||
|
- Increased top margin on headlines in reader view
|
||||||
|
- Improved link visibility in dark mode with lighter indigo-400 color
|
||||||
|
- Default Nostr gateway changed to njump.to
|
||||||
|
- Node runtime pinned to 22.x via package.json engines
|
||||||
|
- Simplified OpenGraph fetch by removing timeout wrapper and background refresh
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Use sentinel query param for OpenGraph redirect to preserve `/a/:naddr` paths
|
||||||
|
- Gate `/a/:naddr` rewrite to crawlers to prevent refresh redirect
|
||||||
|
- Update preview link color when link color setting changes
|
||||||
|
- Store separate link colors for dark and light themes
|
||||||
|
- Remove unused LINK_COLORS import from ColorPicker
|
||||||
|
- Increase relay fetch timeout from 3s to 5s for better reliability
|
||||||
|
- Improve Redis initialization and add debugging for metadata fetch
|
||||||
|
- Add .js extensions to ESM imports for Vercel compatibility
|
||||||
|
- Move OpenGraph service files to api/services for Vercel compatibility
|
||||||
|
- Resolve linting and type errors
|
||||||
|
- Remove user-agent restriction from article OpenGraph rewrite
|
||||||
|
- Inline profile display name helper to avoid src import in serverless
|
||||||
|
- Move profile helpers to lib and import from API and src to fix serverless import resolution
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Implement early-return article fetch with micro-wait author
|
||||||
|
- Increase relay request timeouts (7s article, 5s profile) to improve reliability
|
||||||
|
- Remove gateway fetch, use relays with short timeout
|
||||||
|
|
||||||
|
### Refactored
|
||||||
|
|
||||||
|
- Use relative path for preview link to work on localhost
|
||||||
|
- Move link to 3rd paragraph and remove 4th paragraph from preview
|
||||||
|
- Update preview link to use real article link instead of sample text
|
||||||
|
- Move profile helpers to shared lib module to keep code DRY
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Remove Development section from README
|
||||||
|
- Update source links to point to specific files
|
||||||
|
- Add source links to basic markdown test files
|
||||||
|
- Add footnotes explaining Bitcoin frequency notation
|
||||||
|
- Add explanatory paragraphs to each test table
|
||||||
|
- Add test account npub and profile link to .env.example
|
||||||
|
- Add comprehensive documentation for publish-markdown script
|
||||||
|
|
||||||
|
## [0.10.33] - 2025-11-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Mobile text selection detection for highlight button using selectionchange event
|
||||||
|
- Normalized index mapping algorithm for whitespace handling in highlights
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Allow nested mark elements for overlapping highlights
|
||||||
|
- Remove unused React import from VideoEmbedProcessor
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Optimize highlight application by collecting text nodes once instead of per highlight (O(n×m) -> O(n+m))
|
||||||
|
- Add caching for highlighted HTML results with TTL and size limits
|
||||||
|
|
||||||
## [0.10.32] - 2025-11-02
|
## [0.10.32] - 2025-11-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
41
api/article-og-refresh.ts
Normal file
41
api/article-og-refresh.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
import { setArticleMeta } from './services/ogStore.js'
|
||||||
|
import { fetchArticleMetadataViaRelays } from './services/articleMeta.js'
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
// Validate refresh secret
|
||||||
|
const providedSecret = req.headers['x-refresh-key']
|
||||||
|
const expectedSecret = process.env.OG_REFRESH_SECRET || ''
|
||||||
|
|
||||||
|
if (providedSecret !== expectedSecret) {
|
||||||
|
console.error('Background refresh unauthorized: secret mismatch')
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const naddr = (req.query.naddr as string | undefined)?.trim()
|
||||||
|
if (!naddr) {
|
||||||
|
return res.status(400).json({ error: 'Missing naddr parameter' })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Background refresh started for ${naddr}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch metadata via relays (WebSockets) - no timeout, let it take as long as needed
|
||||||
|
const meta = await fetchArticleMetadataViaRelays(naddr)
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
console.log(`Background refresh found metadata for ${naddr}:`, { title: meta.title, summary: meta.summary?.substring(0, 50) })
|
||||||
|
// Store in Redis
|
||||||
|
await setArticleMeta(naddr, meta)
|
||||||
|
console.log(`Background refresh cached metadata for ${naddr}`)
|
||||||
|
return res.status(200).json({ ok: true, cached: true })
|
||||||
|
} else {
|
||||||
|
console.log(`Background refresh found no metadata for ${naddr}`)
|
||||||
|
return res.status(200).json({ ok: true, cached: false })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error refreshing article metadata for ${naddr}:`, err)
|
||||||
|
return res.status(500).json({ error: 'Internal server error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,208 +1,13 @@
|
|||||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { getArticleMeta, setArticleMeta } from './services/ogStore.js'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { fetchArticleMetadataViaRelays } from './services/articleMeta.js'
|
||||||
import { AddressPointer } from 'nostr-tools/nip19'
|
import { generateHtml } from './services/ogHtml.js'
|
||||||
import { NostrEvent, Filter } from 'nostr-tools'
|
|
||||||
import { Helpers } from 'applesauce-core'
|
|
||||||
import { extractProfileDisplayName } from '../src/utils/profileUtils'
|
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
|
|
||||||
|
|
||||||
// Relay configuration (from src/config/relays.ts)
|
|
||||||
const RELAYS = [
|
|
||||||
'wss://relay.damus.io',
|
|
||||||
'wss://nos.lol',
|
|
||||||
'wss://relay.nostr.band',
|
|
||||||
'wss://relay.dergigi.com',
|
|
||||||
'wss://wot.dergigi.com',
|
|
||||||
'wss://relay.snort.social',
|
|
||||||
'wss://nostr-pub.wellorder.net',
|
|
||||||
'wss://purplepag.es',
|
|
||||||
'wss://relay.primal.net'
|
|
||||||
]
|
|
||||||
|
|
||||||
type CacheEntry = {
|
|
||||||
html: string
|
|
||||||
expires: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
|
||||||
const memoryCache = new Map<string, CacheEntry>()
|
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
|
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
|
||||||
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ArticleMetadata {
|
|
||||||
title: string
|
|
||||||
summary: string
|
|
||||||
image: string
|
|
||||||
author: string
|
|
||||||
published?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchEventsFromRelays(
|
|
||||||
relayPool: RelayPool,
|
|
||||||
relayUrls: string[],
|
|
||||||
filter: Filter,
|
|
||||||
timeoutMs: number
|
|
||||||
): Promise<NostrEvent[]> {
|
|
||||||
const events: NostrEvent[] = []
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const timeout = setTimeout(() => resolve(), timeoutMs)
|
|
||||||
|
|
||||||
// `request` emits NostrEvent objects directly
|
|
||||||
relayPool.request(relayUrls, filter).subscribe({
|
|
||||||
next: (event) => {
|
|
||||||
events.push(event)
|
|
||||||
},
|
|
||||||
error: () => resolve(),
|
|
||||||
complete: () => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sort by created_at and return most recent first
|
|
||||||
return events.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | null> {
|
|
||||||
const relayPool = new RelayPool()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Decode naddr
|
|
||||||
const decoded = nip19.decode(naddr)
|
|
||||||
if (decoded.type !== 'naddr') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const pointer = decoded.data as AddressPointer
|
|
||||||
|
|
||||||
// Determine relay URLs
|
|
||||||
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
|
|
||||||
|
|
||||||
// Fetch article and profile in parallel
|
|
||||||
const [articleEvents, profileEvents] = await Promise.all([
|
|
||||||
fetchEventsFromRelays(relayPool, relayUrls, {
|
|
||||||
kinds: [pointer.kind],
|
|
||||||
authors: [pointer.pubkey],
|
|
||||||
'#d': [pointer.identifier || '']
|
|
||||||
}, 5000),
|
|
||||||
fetchEventsFromRelays(relayPool, relayUrls, {
|
|
||||||
kinds: [0],
|
|
||||||
authors: [pointer.pubkey]
|
|
||||||
}, 3000)
|
|
||||||
])
|
|
||||||
|
|
||||||
if (articleEvents.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const article = articleEvents[0]
|
|
||||||
|
|
||||||
// Extract article metadata
|
|
||||||
const title = getArticleTitle(article) || 'Untitled Article'
|
|
||||||
const summary = getArticleSummary(article) || 'Read this article on Boris'
|
|
||||||
const image = getArticleImage(article) || '/boris-social-1200.png'
|
|
||||||
|
|
||||||
// Extract author name from profile using centralized utility
|
|
||||||
let authorName = pointer.pubkey.slice(0, 8) + '...'
|
|
||||||
if (profileEvents.length > 0) {
|
|
||||||
const displayName = extractProfileDisplayName(profileEvents[0])
|
|
||||||
if (displayName && !displayName.startsWith('@')) {
|
|
||||||
authorName = displayName
|
|
||||||
} else if (displayName) {
|
|
||||||
authorName = displayName.substring(1) // Remove @ prefix
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
summary,
|
|
||||||
image,
|
|
||||||
author: authorName,
|
|
||||||
published: article.created_at
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch article metadata:', err)
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
// No explicit close needed; pool manages connections internally
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
|
|
||||||
const baseUrl = 'https://read.withboris.com'
|
|
||||||
const articleUrl = `${baseUrl}/a/${naddr}`
|
|
||||||
|
|
||||||
const title = meta?.title || 'Boris – Read, Highlight, Explore'
|
|
||||||
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
|
|
||||||
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
|
|
||||||
const author = meta?.author || 'Boris'
|
|
||||||
|
|
||||||
return `<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
||||||
<meta name="theme-color" content="#0f172a" />
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
|
||||||
<title>${escapeHtml(title)}</title>
|
|
||||||
<meta name="description" content="${escapeHtml(description)}" />
|
|
||||||
<link rel="canonical" href="${articleUrl}" />
|
|
||||||
|
|
||||||
<!-- Open Graph / Social Media -->
|
|
||||||
<meta property="og:type" content="article" />
|
|
||||||
<meta property="og:url" content="${articleUrl}" />
|
|
||||||
<meta property="og:title" content="${escapeHtml(title)}" />
|
|
||||||
<meta property="og:description" content="${escapeHtml(description)}" />
|
|
||||||
<meta property="og:image" content="${escapeHtml(image)}" />
|
|
||||||
<meta property="og:site_name" content="Boris" />
|
|
||||||
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
|
|
||||||
<meta property="article:author" content="${escapeHtml(author)}" />
|
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:url" content="${articleUrl}" />
|
|
||||||
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
|
||||||
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
|
||||||
<meta name="twitter:image" content="${escapeHtml(image)}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>
|
|
||||||
<p>Redirecting to <a href="/">Boris</a>...</p>
|
|
||||||
</noscript>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCrawler(userAgent: string | undefined): boolean {
|
|
||||||
if (!userAgent) return false
|
|
||||||
const crawlers = [
|
|
||||||
'bot', 'crawl', 'spider', 'slurp', 'facebook', 'twitter', 'linkedin',
|
|
||||||
'whatsapp', 'telegram', 'slack', 'discord', 'preview'
|
|
||||||
]
|
|
||||||
const ua = userAgent.toLowerCase()
|
|
||||||
return crawlers.some(crawler => ua.includes(crawler))
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
const naddr = (req.query.naddr as string | undefined)?.trim()
|
const naddr = (req.query.naddr as string | undefined)?.trim()
|
||||||
|
|
||||||
@@ -210,89 +15,46 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|||||||
return res.status(400).json({ error: 'Missing naddr parameter' })
|
return res.status(400).json({ error: 'Missing naddr parameter' })
|
||||||
}
|
}
|
||||||
|
|
||||||
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'
|
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
res.setHeader('X-Boris-Debug', '1')
|
res.setHeader('X-Boris-Debug', '1')
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's a regular browser (not a bot), serve HTML that loads SPA
|
// Try Redis cache first
|
||||||
// Use history.replaceState to set the URL before the SPA boots
|
let meta = await getArticleMeta(naddr).catch((err) => {
|
||||||
if (!isCrawlerRequest) {
|
console.error('Failed to get article meta from Redis:', err)
|
||||||
const articlePath = `/a/${naddr}`
|
return null
|
||||||
// Serve a minimal HTML that sets up the URL and loads the SPA
|
})
|
||||||
const html = `<!DOCTYPE html>
|
let cacheMaxAge = 86400
|
||||||
<html lang="en">
|
|
||||||
<head>
|
if (!meta) {
|
||||||
<meta charset="UTF-8">
|
// Cache miss: fetch from relays (let it use its natural timeouts)
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
try {
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
meta = await fetchArticleMetadataViaRelays(naddr)
|
||||||
<title>Boris - Loading Article...</title>
|
|
||||||
<script>
|
if (meta) {
|
||||||
// Set the URL to the article path before SPA loads
|
// Store in Redis and use it
|
||||||
if (window.location.pathname !== '${articlePath}') {
|
await setArticleMeta(naddr, meta).catch((err) => {
|
||||||
history.replaceState(null, '', '${articlePath}');
|
console.error('Failed to cache relay metadata:', err)
|
||||||
|
})
|
||||||
|
cacheMaxAge = 86400
|
||||||
|
} else {
|
||||||
|
// Relay fetch failed: use default fallback
|
||||||
|
cacheMaxAge = 300
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error fetching from relays for ${naddr}:`, err)
|
||||||
|
cacheMaxAge = 300
|
||||||
}
|
}
|
||||||
</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
|
|
||||||
window.location.replace('/');
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
|
||||||
if (debugEnabled) {
|
|
||||||
// Debug mode enabled
|
|
||||||
}
|
|
||||||
return res.status(200).send(html)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache for bots/crawlers
|
// Generate and send HTML
|
||||||
const now = Date.now()
|
const html = generateHtml(naddr, meta)
|
||||||
const cached = memoryCache.get(naddr)
|
setCacheHeaders(res, cacheMaxAge)
|
||||||
if (cached && cached.expires > now) {
|
|
||||||
setCacheHeaders(res)
|
if (debugEnabled) {
|
||||||
if (debugEnabled) {
|
// Debug mode enabled
|
||||||
// Debug mode enabled
|
|
||||||
}
|
|
||||||
return res.status(200).send(cached.html)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch metadata
|
|
||||||
const meta = await fetchArticleMetadata(naddr)
|
|
||||||
|
|
||||||
// Generate HTML
|
|
||||||
const html = generateHtml(naddr, meta)
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
memoryCache.set(naddr, { html, expires: now + WEEK_MS })
|
|
||||||
|
|
||||||
// Send response
|
|
||||||
setCacheHeaders(res)
|
|
||||||
if (debugEnabled) {
|
|
||||||
// Debug mode enabled
|
|
||||||
}
|
|
||||||
return res.status(200).send(html)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error generating article OG HTML:', err)
|
|
||||||
|
|
||||||
// Fallback to basic HTML with SPA boot
|
|
||||||
const html = generateHtml(naddr, null)
|
|
||||||
setCacheHeaders(res, 3600)
|
|
||||||
if (debugEnabled) {
|
|
||||||
// Debug mode enabled
|
|
||||||
}
|
|
||||||
return res.status(200).send(html)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
224
api/services/articleMeta.ts
Normal file
224
api/services/articleMeta.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import WebSocket from 'ws'
|
||||||
|
;(globalThis as unknown as { WebSocket?: typeof WebSocket }).WebSocket ??= WebSocket
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { AddressPointer } from 'nostr-tools/nip19'
|
||||||
|
import { NostrEvent, Filter } from 'nostr-tools'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
import { extractProfileDisplayName } from '../../lib/profile.js'
|
||||||
|
import { RELAYS } from '../../src/config/relays.js'
|
||||||
|
import type { ArticleMetadata } from './ogStore.js'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
|
||||||
|
|
||||||
|
async function fetchEventsFromRelays(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
relayUrls: string[],
|
||||||
|
filter: Filter,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<NostrEvent[]> {
|
||||||
|
const events: NostrEvent[] = []
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => resolve(), timeoutMs)
|
||||||
|
|
||||||
|
relayPool.request(relayUrls, filter).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
events.push(event)
|
||||||
|
},
|
||||||
|
error: () => resolve(),
|
||||||
|
complete: () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFirstEvent(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
relayUrls: string[],
|
||||||
|
filter: Filter,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<NostrEvent | null> {
|
||||||
|
return new Promise<NostrEvent | null>((resolve) => {
|
||||||
|
let resolved = false
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
const subscription = relayPool.request(relayUrls, filter).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
subscription.unsubscribe()
|
||||||
|
resolve(event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAuthorProfile(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
relayUrls: string[],
|
||||||
|
pubkey: string,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<string | null> {
|
||||||
|
const profileEvents = await fetchEventsFromRelays(relayPool, relayUrls, {
|
||||||
|
kinds: [0],
|
||||||
|
authors: [pubkey]
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
if (profileEvents.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = extractProfileDisplayName(profileEvents[0])
|
||||||
|
if (displayName && !displayName.startsWith('@')) {
|
||||||
|
return displayName
|
||||||
|
} else if (displayName) {
|
||||||
|
return displayName.substring(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchArticleMetadataViaRelays(naddr: string): Promise<ArticleMetadata | null> {
|
||||||
|
const relayPool = new RelayPool()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type !== 'naddr') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
|
||||||
|
|
||||||
|
// Step A: Fetch article - return as soon as first event arrives
|
||||||
|
const article = await fetchFirstEvent(relayPool, relayUrls, {
|
||||||
|
kinds: [pointer.kind],
|
||||||
|
authors: [pointer.pubkey],
|
||||||
|
'#d': [pointer.identifier || '']
|
||||||
|
}, 7000)
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step B: Extract article metadata immediately
|
||||||
|
const title = getArticleTitle(article) || 'Untitled Article'
|
||||||
|
const summary = getArticleSummary(article) || 'Read this article on Boris'
|
||||||
|
const image = getArticleImage(article) || '/boris-social-1200.png'
|
||||||
|
|
||||||
|
// Extract 't' tags (topic tags) from article event
|
||||||
|
const tags = article.tags
|
||||||
|
?.filter((tag) => tag[0] === 't' && tag[1])
|
||||||
|
.map((tag) => tag[1])
|
||||||
|
.filter((tag) => tag.length > 0) || []
|
||||||
|
|
||||||
|
// Generate image alt text (use title as fallback)
|
||||||
|
const imageAlt = title || 'Article cover image'
|
||||||
|
|
||||||
|
// Step C: Fetch author profile with micro-wait (connections already warm)
|
||||||
|
let authorName = await fetchAuthorProfile(relayPool, relayUrls, pointer.pubkey, 400)
|
||||||
|
|
||||||
|
// Step D: Optional hedge - try again with slightly longer timeout if first attempt failed
|
||||||
|
if (!authorName) {
|
||||||
|
authorName = await fetchAuthorProfile(relayPool, relayUrls, pointer.pubkey, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authorName) {
|
||||||
|
authorName = pointer.pubkey.slice(0, 8) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
image,
|
||||||
|
author: authorName,
|
||||||
|
published: article.created_at,
|
||||||
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
|
imageAlt
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch article metadata via relays:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchArticleMetadataViaGateway(naddr: string): Promise<ArticleMetadata | null> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 2000)
|
||||||
|
|
||||||
|
const url = `https://njump.to/${naddr}`
|
||||||
|
console.log(`Fetching from gateway: ${url}`)
|
||||||
|
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
clearTimeout(timeout)
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.error(`Gateway fetch failed: ${resp.status} ${resp.statusText} for ${url}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await resp.text()
|
||||||
|
console.log(`Gateway response length: ${html.length} chars`)
|
||||||
|
|
||||||
|
const pick = (re: RegExp) => {
|
||||||
|
const match = html.match(re)
|
||||||
|
return match?.[1] ? match[1].trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = pick(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) ||
|
||||||
|
pick(/<title[^>]*>([^<]+)<\/title>/i)
|
||||||
|
const summary = pick(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i)
|
||||||
|
const image = pick(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)
|
||||||
|
|
||||||
|
console.log(`Parsed from gateway - title: ${title ? 'found' : 'missing'}, summary: ${summary ? 'found' : 'missing'}, image: ${image ? 'found' : 'missing'}`)
|
||||||
|
|
||||||
|
if (!title && !summary && !image) {
|
||||||
|
console.log('No OG metadata found in gateway response')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title || 'Read on Boris',
|
||||||
|
summary: summary || 'Read this article on Boris',
|
||||||
|
image: image || '/boris-social-1200.png',
|
||||||
|
author: 'Boris'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch article metadata via gateway:', err)
|
||||||
|
if (err instanceof Error) {
|
||||||
|
console.error('Error details:', err.message, err.stack)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
80
api/services/ogHtml.ts
Normal file
80
api/services/ogHtml.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ArticleMetadata } from './ogStore.js'
|
||||||
|
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
|
||||||
|
const baseUrl = 'https://read.withboris.com'
|
||||||
|
const articleUrl = `${baseUrl}/a/${naddr}`
|
||||||
|
|
||||||
|
const title = meta?.title || 'Boris – Read, Highlight, Explore'
|
||||||
|
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
|
||||||
|
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
|
||||||
|
const author = meta?.author || 'Boris'
|
||||||
|
const imageAlt = meta?.imageAlt || title
|
||||||
|
|
||||||
|
// Generate article:tag meta tags
|
||||||
|
const articleTags = meta?.tags && meta.tags.length > 0
|
||||||
|
? meta.tags.map((tag) => ` <meta property="article:tag" content="${escapeHtml(tag)}" />`).join('\n')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<meta name="description" content="${escapeHtml(description)}" />
|
||||||
|
<link rel="canonical" href="${articleUrl}" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Social Media -->
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:url" content="${articleUrl}" />
|
||||||
|
<meta property="og:title" content="${escapeHtml(title)}" />
|
||||||
|
<meta property="og:description" content="${escapeHtml(description)}" />
|
||||||
|
<meta property="og:image" content="${escapeHtml(image)}" />
|
||||||
|
<meta property="og:image:alt" content="${escapeHtml(imageAlt)}" />
|
||||||
|
<meta property="og:site_name" content="Boris" />
|
||||||
|
${meta?.published ? ` <meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
|
||||||
|
<meta property="article:author" content="${escapeHtml(author)}" />
|
||||||
|
${articleTags}
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:url" content="${articleUrl}" />
|
||||||
|
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
||||||
|
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
||||||
|
<meta name="twitter:image" content="${escapeHtml(image)}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<p>Redirecting to <a href="/a/${naddr}">Boris</a>...</p>
|
||||||
|
</noscript>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
var p = '/a/${naddr}';
|
||||||
|
if (window.location.pathname !== p) {
|
||||||
|
history.replaceState(null, '', p);
|
||||||
|
}
|
||||||
|
var sep = window.location.search ? '&' : '?';
|
||||||
|
window.location.replace(p + sep + '_spa=1');
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
39
api/services/ogStore.ts
Normal file
39
api/services/ogStore.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Redis } from '@upstash/redis'
|
||||||
|
|
||||||
|
// Support both KV_* and UPSTASH_* env var names
|
||||||
|
const redisUrl = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL
|
||||||
|
const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN
|
||||||
|
const readOnlyToken = process.env.KV_REST_API_READ_ONLY_TOKEN
|
||||||
|
|
||||||
|
if (!redisUrl || !redisToken) {
|
||||||
|
console.error('Missing Redis credentials: UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN or KV_REST_API_URL/KV_REST_API_TOKEN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const redisWrite = redisUrl && redisToken
|
||||||
|
? new Redis({ url: redisUrl, token: redisToken })
|
||||||
|
: Redis.fromEnv() // Fallback to fromEnv() if explicit vars not set
|
||||||
|
|
||||||
|
const redisRead = readOnlyToken && redisUrl
|
||||||
|
? new Redis({ url: redisUrl, token: readOnlyToken })
|
||||||
|
: redisWrite
|
||||||
|
|
||||||
|
const keyOf = (naddr: string) => `og:${naddr}`
|
||||||
|
|
||||||
|
export type ArticleMetadata = {
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
image: string
|
||||||
|
author: string
|
||||||
|
published?: number
|
||||||
|
tags?: string[]
|
||||||
|
imageAlt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArticleMeta(naddr: string): Promise<ArticleMetadata | null> {
|
||||||
|
return (await redisRead.get<ArticleMetadata>(keyOf(naddr))) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setArticleMeta(naddr: string, meta: ArticleMetadata, ttlSec = 604800): Promise<void> {
|
||||||
|
await redisWrite.set(keyOf(naddr), meta, { ex: ttlSec })
|
||||||
|
}
|
||||||
|
|
||||||
@@ -27,7 +27,10 @@
|
|||||||
<meta name="twitter:title" content="Boris - Read, Highlight, Explore" />
|
<meta name="twitter:title" content="Boris - Read, Highlight, Explore" />
|
||||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||||
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||||
|
|
||||||
|
<!-- Fathom -->
|
||||||
|
<script src="https://cdn.usefathom.com/script.js" data-site="LLSGRVAP" defer></script>
|
||||||
|
|
||||||
<!-- Default to system theme until settings load from Nostr -->
|
<!-- Default to system theme until settings load from Nostr -->
|
||||||
<script>
|
<script>
|
||||||
document.documentElement.className = 'theme-system';
|
document.documentElement.className = 'theme-system';
|
||||||
|
|||||||
39
lib/profile.ts
Normal file
39
lib/profile.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import type { NostrEvent } from 'nostr-tools'
|
||||||
|
|
||||||
|
export function getNpubFallbackDisplay(pubkey: string): string {
|
||||||
|
try {
|
||||||
|
const npub = nip19.npubEncode(pubkey)
|
||||||
|
return `${npub.slice(5, 12)}...`
|
||||||
|
} catch {
|
||||||
|
return `${pubkey.slice(0, 8)}...`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractProfileDisplayName(profileEvent: NostrEvent | null | undefined): string {
|
||||||
|
if (!profileEvent || profileEvent.kind !== 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profileData = JSON.parse(profileEvent.content || '{}') as {
|
||||||
|
name?: string
|
||||||
|
display_name?: string
|
||||||
|
nip05?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileData.name) return profileData.name
|
||||||
|
if (profileData.display_name) return profileData.display_name
|
||||||
|
if (profileData.nip05) return profileData.nip05
|
||||||
|
|
||||||
|
return getNpubFallbackDisplay(profileEvent.pubkey)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return getNpubFallbackDisplay(profileEvent.pubkey)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
82
package-lock.json
generated
82
package-lock.json
generated
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.23",
|
"version": "0.10.33",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.23",
|
"version": "0.10.33",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||||
|
"@upstash/redis": "^1.35.6",
|
||||||
"@vercel/node": "^5.3.26",
|
"@vercel/node": "^5.3.26",
|
||||||
"applesauce-accounts": "^4.0.0",
|
"applesauce-accounts": "^4.0.0",
|
||||||
"applesauce-content": "^4.0.0",
|
"applesauce-content": "^4.0.0",
|
||||||
@@ -37,12 +38,14 @@
|
|||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tinyld": "^1.3.4",
|
"tinyld": "^1.3.4",
|
||||||
"use-pull-to-refresh": "^2.4.1"
|
"use-pull-to-refresh": "^2.4.1",
|
||||||
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.14",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
@@ -56,6 +59,9 @@
|
|||||||
"vite": "^5.0.8",
|
"vite": "^5.0.8",
|
||||||
"vite-plugin-pwa": "^1.0.3",
|
"vite-plugin-pwa": "^1.0.3",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "22.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -102,6 +108,7 @@
|
|||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
@@ -2262,6 +2269,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
|
||||||
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
|
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-common-types": "7.1.0"
|
"@fortawesome/fontawesome-common-types": "7.1.0"
|
||||||
},
|
},
|
||||||
@@ -3553,6 +3561,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
|
||||||
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -3595,6 +3604,16 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
||||||
@@ -3637,6 +3656,7 @@
|
|||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
@@ -3799,6 +3819,15 @@
|
|||||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@upstash/redis": {
|
||||||
|
"version": "1.35.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.35.6.tgz",
|
||||||
|
"integrity": "sha512-aSEIGJgJ7XUfTYvhQcQbq835re7e/BXjs8Janq6Pvr6LlmTZnyqwT97RziZLO/8AVUL037RLXqqiQC6kCt+5pA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uncrypto": "^0.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vercel/build-utils": {
|
"node_modules/@vercel/build-utils": {
|
||||||
"version": "12.1.2",
|
"version": "12.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz",
|
||||||
@@ -3927,7 +3956,8 @@
|
|||||||
"version": "16.18.11",
|
"version": "16.18.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz",
|
||||||
"integrity": "sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==",
|
"integrity": "sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@vercel/node/node_modules/esbuild": {
|
"node_modules/@vercel/node/node_modules/esbuild": {
|
||||||
"version": "0.14.47",
|
"version": "0.14.47",
|
||||||
@@ -4012,6 +4042,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -4088,6 +4119,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4640,6 +4672,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.9",
|
"baseline-browser-mapping": "^2.8.9",
|
||||||
"caniuse-lite": "^1.0.30001746",
|
"caniuse-lite": "^1.0.30001746",
|
||||||
@@ -5876,6 +5909,7 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -9711,6 +9745,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -9857,6 +9892,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -9869,6 +9905,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -10434,6 +10471,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -11189,6 +11227,7 @@
|
|||||||
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
|
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.15.0",
|
"acorn": "^8.15.0",
|
||||||
@@ -11265,6 +11304,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -11517,6 +11557,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -11544,6 +11585,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uncrypto": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "5.28.4",
|
"version": "5.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
|
||||||
@@ -11560,8 +11607,7 @@
|
|||||||
"version": "7.14.0",
|
"version": "7.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
|
||||||
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
|
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
@@ -11842,6 +11888,7 @@
|
|||||||
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
|
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -12227,6 +12274,7 @@
|
|||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -12271,6 +12319,7 @@
|
|||||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
},
|
},
|
||||||
@@ -12519,6 +12568,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
24
package.json
24
package.json
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.33",
|
"version": "0.11.1",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": "22.x"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"publish:test:markdown": "./scripts/publish-markdown.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
@@ -16,6 +20,7 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||||
|
"@upstash/redis": "^1.35.6",
|
||||||
"@vercel/node": "^5.3.26",
|
"@vercel/node": "^5.3.26",
|
||||||
"applesauce-accounts": "^4.0.0",
|
"applesauce-accounts": "^4.0.0",
|
||||||
"applesauce-content": "^4.0.0",
|
"applesauce-content": "^4.0.0",
|
||||||
@@ -40,12 +45,14 @@
|
|||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tinyld": "^1.3.4",
|
"tinyld": "^1.3.4",
|
||||||
"use-pull-to-refresh": "^2.4.1"
|
"use-pull-to-refresh": "^2.4.1",
|
||||||
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.14",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
@@ -97,6 +104,15 @@
|
|||||||
"@typescript-eslint/no-explicit-any": "warn",
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
"prefer-const": "error",
|
"prefer-const": "error",
|
||||||
"no-var": "error"
|
"no-var": "error"
|
||||||
}
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["api/**/*.ts"],
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"browser": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
202
scripts/publish-markdown.sh
Executable file
202
scripts/publish-markdown.sh
Executable file
@@ -0,0 +1,202 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to publish markdown files from test/markdown/ to Nostr using nak
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/publish-markdown.sh [filename] [relay1] [relay2] ...
|
||||||
|
# ./scripts/publish-markdown.sh # Interactive mode
|
||||||
|
# ./scripts/publish-markdown.sh tables.md # Publish specific file
|
||||||
|
# ./scripts/publish-markdown.sh tables.md wss://relay.example.com # With relay
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# The script reads .env from the project root directory ($PROJECT_ROOT/.env)
|
||||||
|
# Required: NOSTR_SECRET_KEY (your nsec, ncryptsec, or hex format key)
|
||||||
|
# Optional: RELAYS (space-separated list of relay URLs)
|
||||||
|
#
|
||||||
|
# Test account for markdown test documents:
|
||||||
|
# npub: npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2
|
||||||
|
# Profile: https://read.withboris.com/p/npub1marky39a9qmadyuux9lr49pdhy3ddxrdwtmd9y957kye66qyu3vq7spdm2/writings
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
MARKDOWN_DIR="$PROJECT_ROOT/test/markdown"
|
||||||
|
ENV_FILE="$PROJECT_ROOT/.env"
|
||||||
|
|
||||||
|
# Load .env file if it exists
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
# Source the .env file, handling quoted values properly
|
||||||
|
set -a # Automatically export all variables
|
||||||
|
# Use eval to properly handle quoted values (safe since we control the file)
|
||||||
|
# This handles both unquoted and quoted values correctly
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
# Skip comments and empty lines
|
||||||
|
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
# Remove leading/trailing whitespace
|
||||||
|
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||||
|
# Export the variable (handles quoted values)
|
||||||
|
eval "export $line"
|
||||||
|
done < "$ENV_FILE"
|
||||||
|
set +a # Stop automatically exporting
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if nak is installed
|
||||||
|
if ! command -v nak &> /dev/null; then
|
||||||
|
echo "Error: nak is not installed or not in PATH"
|
||||||
|
echo "Install from: https://github.com/fiatjaf/nak"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to publish a markdown file
|
||||||
|
publish_file() {
|
||||||
|
local file_path="$1"
|
||||||
|
shift # Remove first argument, rest are relay URLs
|
||||||
|
local relays=("$@")
|
||||||
|
local filename=$(basename "$file_path")
|
||||||
|
local identifier="${filename%.md}" # Remove .md extension
|
||||||
|
|
||||||
|
echo "📝 Publishing: $filename"
|
||||||
|
echo " Identifier: $identifier"
|
||||||
|
|
||||||
|
# Extract title from first H1 if available, otherwise use filename
|
||||||
|
local title=$(grep -m 1 "^# " "$file_path" | sed 's/^# //' || echo "$identifier")
|
||||||
|
|
||||||
|
# Add relays if provided
|
||||||
|
if [ ${#relays[@]} -gt 0 ]; then
|
||||||
|
echo " Relays: ${relays[*]}"
|
||||||
|
else
|
||||||
|
echo " Note: No relays specified. Event will be created but not published."
|
||||||
|
echo " Add relay URLs as arguments to publish, e.g.: wss://relay.example.com"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Publish as kind 30023 (NIP-23 blog post)
|
||||||
|
# The "d" tag is required for replaceable events (kind 30023)
|
||||||
|
# Using the filename (without extension) as the identifier
|
||||||
|
# Build command array to avoid eval issues
|
||||||
|
# Use @filename syntax to read content from file (nak supports this)
|
||||||
|
local cmd_args=(
|
||||||
|
"event"
|
||||||
|
"-k" "30023"
|
||||||
|
"-d" "$identifier"
|
||||||
|
"-t" "title=$title"
|
||||||
|
"--content" "@$file_path"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add relays if provided
|
||||||
|
if [ ${#relays[@]} -gt 0 ]; then
|
||||||
|
cmd_args+=("${relays[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
nak "${cmd_args[@]}"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Successfully published: $filename"
|
||||||
|
else
|
||||||
|
echo "❌ Failed to publish: $filename"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for NOSTR_SECRET_KEY
|
||||||
|
if [ -z "$NOSTR_SECRET_KEY" ]; then
|
||||||
|
echo "⚠️ Warning: NOSTR_SECRET_KEY environment variable not set"
|
||||||
|
echo " Set it in .env file or with: export NOSTR_SECRET_KEY=your_key_here"
|
||||||
|
echo " Or use --prompt-sec flag (nak will prompt for key)"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse RELAYS from environment if set
|
||||||
|
default_relays=()
|
||||||
|
if [ -n "$RELAYS" ]; then
|
||||||
|
# Split RELAYS string into array
|
||||||
|
read -ra default_relays <<< "$RELAYS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Main logic
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
# No arguments: list all markdown files and let user choose
|
||||||
|
echo "Available markdown files:"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
files=("$MARKDOWN_DIR"/*.md)
|
||||||
|
if [ ! -e "${files[0]}" ]; then
|
||||||
|
echo "No markdown files found in $MARKDOWN_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display files with numbers
|
||||||
|
declare -a file_array
|
||||||
|
i=1
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
filename=$(basename "$file")
|
||||||
|
echo " $i) $filename"
|
||||||
|
file_array[$i]="$file"
|
||||||
|
((i++))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Enter file number(s) to publish (space-separated), or 'all' for all files:"
|
||||||
|
read -r selection
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ ${#default_relays[@]} -gt 0 ]; then
|
||||||
|
echo "Enter relay URLs (space-separated, or press Enter to use defaults from .env):"
|
||||||
|
echo " Defaults: ${default_relays[*]}"
|
||||||
|
else
|
||||||
|
echo "Enter relay URLs (space-separated, or press Enter to skip):"
|
||||||
|
fi
|
||||||
|
read -r relay_input
|
||||||
|
|
||||||
|
# Parse relay URLs
|
||||||
|
relays=()
|
||||||
|
if [ -n "$relay_input" ]; then
|
||||||
|
read -ra relays <<< "$relay_input"
|
||||||
|
elif [ ${#default_relays[@]} -gt 0 ]; then
|
||||||
|
# Use defaults from .env
|
||||||
|
relays=("${default_relays[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$selection" = "all" ]; then
|
||||||
|
# Publish all files
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
publish_file "$file" "${relays[@]}"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
else
|
||||||
|
# Publish selected files
|
||||||
|
for num in $selection; do
|
||||||
|
if [ -n "${file_array[$num]}" ]; then
|
||||||
|
publish_file "${file_array[$num]}" "${relays[@]}"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "⚠️ Invalid selection: $num"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Argument provided: publish specific file
|
||||||
|
filename="$1"
|
||||||
|
shift # Remove filename, rest are relay URLs
|
||||||
|
relays=("$@")
|
||||||
|
|
||||||
|
# If no relays provided as arguments, use defaults from .env
|
||||||
|
if [ ${#relays[@]} -eq 0 ] && [ ${#default_relays[@]} -gt 0 ]; then
|
||||||
|
relays=("${default_relays[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If filename doesn't end with .md, add it
|
||||||
|
if [[ ! "$filename" =~ \.md$ ]]; then
|
||||||
|
filename="${filename}.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
file_path="$MARKDOWN_DIR/$filename"
|
||||||
|
|
||||||
|
if [ ! -f "$file_path" ]; then
|
||||||
|
echo "Error: File not found: $file_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
publish_file "$file_path" "${relays[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
82
src/App.tsx
82
src/App.tsx
@@ -576,6 +576,31 @@ function App() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Helper to update keep-alive subscription based on current active relays
|
||||||
|
const updateKeepAlive = (relayUrls?: string[]) => {
|
||||||
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
|
poolWithSub._keepAliveSubscription.unsubscribe()
|
||||||
|
}
|
||||||
|
const targetRelays = relayUrls || getActiveRelayUrls(pool)
|
||||||
|
const newKeepAliveSub = pool.subscription(targetRelays, { kinds: [0], limit: 0 }).subscribe({
|
||||||
|
next: () => {},
|
||||||
|
error: () => {}
|
||||||
|
})
|
||||||
|
poolWithSub._keepAliveSubscription = newKeepAliveSub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to update address loader based on current active relays
|
||||||
|
const updateAddressLoader = (relayUrls?: string[]) => {
|
||||||
|
const targetRelays = relayUrls || getActiveRelayUrls(pool)
|
||||||
|
const addressLoader = createAddressLoader(pool, {
|
||||||
|
eventStore: store,
|
||||||
|
lookupRelays: targetRelays
|
||||||
|
})
|
||||||
|
store.addressableLoader = addressLoader
|
||||||
|
store.replaceableLoader = addressLoader
|
||||||
|
}
|
||||||
|
|
||||||
// Handle user relay list and blocked relays when account changes
|
// Handle user relay list and blocked relays when account changes
|
||||||
const userRelaysSub = accounts.active$.subscribe((account) => {
|
const userRelaysSub = accounts.active$.subscribe((account) => {
|
||||||
if (account) {
|
if (account) {
|
||||||
@@ -604,20 +629,6 @@ function App() {
|
|||||||
// Apply initial set immediately
|
// Apply initial set immediately
|
||||||
applyRelaySetToPool(pool, initialRelays)
|
applyRelaySetToPool(pool, initialRelays)
|
||||||
|
|
||||||
// Prepare keep-alive helper
|
|
||||||
const updateKeepAlive = () => {
|
|
||||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
|
||||||
if (poolWithSub._keepAliveSubscription) {
|
|
||||||
poolWithSub._keepAliveSubscription.unsubscribe()
|
|
||||||
}
|
|
||||||
const activeRelays = getActiveRelayUrls(pool)
|
|
||||||
const newKeepAliveSub = pool.subscription(activeRelays, { kinds: [0], limit: 0 }).subscribe({
|
|
||||||
next: () => {},
|
|
||||||
error: () => {}
|
|
||||||
})
|
|
||||||
poolWithSub._keepAliveSubscription = newKeepAliveSub
|
|
||||||
}
|
|
||||||
|
|
||||||
// Begin loading blocked relays in background
|
// Begin loading blocked relays in background
|
||||||
const blockedPromise = loadBlockedRelays(pool, pubkey)
|
const blockedPromise = loadBlockedRelays(pool, pubkey)
|
||||||
|
|
||||||
@@ -649,43 +660,16 @@ function App() {
|
|||||||
applyRelaySetToPool(pool, finalRelays)
|
applyRelaySetToPool(pool, finalRelays)
|
||||||
|
|
||||||
updateKeepAlive()
|
updateKeepAlive()
|
||||||
|
updateAddressLoader()
|
||||||
// Update address loader with new relays
|
|
||||||
const activeRelays = getActiveRelayUrls(pool)
|
|
||||||
const addressLoader = createAddressLoader(pool, {
|
|
||||||
eventStore: store,
|
|
||||||
lookupRelays: activeRelays
|
|
||||||
})
|
|
||||||
store.addressableLoader = addressLoader
|
|
||||||
store.replaceableLoader = addressLoader
|
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error)
|
console.error('[relay-init] Failed to load user relay list (continuing with initial set):', error)
|
||||||
// Continue with initial relay set on error - no need to change anything
|
// Continue with initial relay set on error - no need to change anything
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// User logged out - reset to hardcoded relays
|
// User logged out - reset to hardcoded relays
|
||||||
|
|
||||||
applyRelaySetToPool(pool, RELAYS)
|
applyRelaySetToPool(pool, RELAYS)
|
||||||
|
updateKeepAlive(RELAYS)
|
||||||
|
updateAddressLoader(RELAYS)
|
||||||
// Update keep-alive subscription
|
|
||||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
|
||||||
if (poolWithSub._keepAliveSubscription) {
|
|
||||||
poolWithSub._keepAliveSubscription.unsubscribe()
|
|
||||||
}
|
|
||||||
const newKeepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
|
||||||
next: () => {},
|
|
||||||
error: () => {}
|
|
||||||
})
|
|
||||||
poolWithSub._keepAliveSubscription = newKeepAliveSub
|
|
||||||
|
|
||||||
// Reset address loader
|
|
||||||
const addressLoader = createAddressLoader(pool, {
|
|
||||||
eventStore: store,
|
|
||||||
lookupRelays: RELAYS
|
|
||||||
})
|
|
||||||
store.addressableLoader = addressLoader
|
|
||||||
store.replaceableLoader = addressLoader
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -755,6 +739,16 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [showToast])
|
}, [showToast])
|
||||||
|
|
||||||
|
// Strip _spa query parameter from URL after SPA loads
|
||||||
|
useEffect(() => {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
if (url.searchParams.has('_spa')) {
|
||||||
|
url.searchParams.delete('_spa')
|
||||||
|
const path = url.pathname + (url.search ? url.search : '') + url.hash
|
||||||
|
window.history.replaceState(null, '', path)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (!eventStore || !accountManager || !relayPool) {
|
if (!eventStore || !accountManager || !relayPool) {
|
||||||
return (
|
return (
|
||||||
<div className="loading">
|
<div className="loading">
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
|||||||
// Reading progress display
|
// Reading progress display
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build article coordinate for navigation state (kind:pubkey:dTag)
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const articleCoordinate = dTag ? `${post.event.kind}:${post.author}:${dTag}` : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
@@ -62,7 +66,9 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
|||||||
image: post.image,
|
image: post.image,
|
||||||
summary: post.summary,
|
summary: post.summary,
|
||||||
published: post.published
|
published: post.published
|
||||||
}
|
},
|
||||||
|
articleCoordinate,
|
||||||
|
eventId: post.event.id
|
||||||
}}
|
}}
|
||||||
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
||||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
|
|||||||
@@ -4,19 +4,20 @@ import { HIGHLIGHT_COLORS } from '../utils/colorHelpers'
|
|||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
selectedColor: string
|
selectedColor: string
|
||||||
onColorChange: (color: string) => void
|
onColorChange: (color: string) => void
|
||||||
|
colors?: typeof HIGHLIGHT_COLORS
|
||||||
}
|
}
|
||||||
|
|
||||||
const ColorPicker: React.FC<ColorPickerProps> = ({ selectedColor, onColorChange }) => {
|
const ColorPicker: React.FC<ColorPickerProps> = ({ selectedColor, onColorChange, colors = HIGHLIGHT_COLORS }) => {
|
||||||
return (
|
return (
|
||||||
<div className="color-picker">
|
<div className="color-picker">
|
||||||
{HIGHLIGHT_COLORS.map(color => (
|
{colors.map(color => (
|
||||||
<button
|
<button
|
||||||
key={color.value}
|
key={color.value}
|
||||||
onClick={() => onColorChange(color.value)}
|
onClick={() => onColorChange(color.value)}
|
||||||
className={`color-swatch ${selectedColor === color.value ? 'active' : ''}`}
|
className={`color-swatch ${selectedColor === color.value ? 'active' : ''}`}
|
||||||
style={{ backgroundColor: color.value }}
|
style={{ backgroundColor: color.value }}
|
||||||
title={color.name}
|
title={color.name}
|
||||||
aria-label={`${color.name} highlight color`}
|
aria-label={`${color.name} color`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { nip19 } from 'nostr-tools'
|
|||||||
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { getActiveRelayUrls } from '../services/relayManager'
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
|
import { isContentRelay } from '../config/relays'
|
||||||
|
import { isLocalRelay } from '../utils/helpers'
|
||||||
import { IAccount } from 'applesauce-accounts'
|
import { IAccount } from 'applesauce-accounts'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
@@ -432,9 +434,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
||||||
const relayHints = activeRelays.filter(r =>
|
const relayHints = activeRelays
|
||||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
.filter(url => !isLocalRelay(url))
|
||||||
).slice(0, 3)
|
.filter(url => isContentRelay(url))
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
const naddr = nip19.naddrEncode({
|
const naddr = nip19.naddrEncode({
|
||||||
kind: 30023,
|
kind: 30023,
|
||||||
|
|||||||
@@ -595,7 +595,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
case 'highlights':
|
case 'highlights':
|
||||||
if (showSkeletons) {
|
if (showSkeletons) {
|
||||||
return (
|
return (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid single-column">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
<HighlightSkeleton key={i} />
|
<HighlightSkeleton key={i} />
|
||||||
))}
|
))}
|
||||||
@@ -607,7 +607,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
<span>No highlights to show for the selected scope.</span>
|
<span>No highlights to show for the selected scope.</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid single-column">
|
||||||
{classifiedHighlights.map((highlight) => (
|
{classifiedHighlights.map((highlight) => (
|
||||||
<HighlightItem
|
<HighlightItem
|
||||||
key={highlight.id}
|
key={highlight.id}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { faComments } from '@fortawesome/free-regular-svg-icons'
|
import { faComments } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
@@ -10,6 +10,7 @@ import { Hooks } from 'applesauce-react'
|
|||||||
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
|
import { onSyncStateChange, isEventSyncing, isEventOfflineCreated } from '../services/offlineSyncService'
|
||||||
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
|
import { areAllRelaysLocal, isLocalRelay } from '../utils/helpers'
|
||||||
import { getActiveRelayUrls } from '../services/relayManager'
|
import { getActiveRelayUrls } from '../services/relayManager'
|
||||||
|
import { isContentRelay, getContentRelays, getFallbackContentRelays } from '../config/relays'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||||
import { createDeletionRequest } from '../services/deletionService'
|
import { createDeletionRequest } from '../services/deletionService'
|
||||||
@@ -179,14 +180,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
}, [showMenu, showDeleteConfirm])
|
}, [showMenu, showDeleteConfirm])
|
||||||
|
|
||||||
const handleItemClick = () => {
|
// Navigate to the article that this highlight references and scroll to the highlight
|
||||||
// If onHighlightClick is provided, use it (legacy behavior)
|
const navigateToArticle = () => {
|
||||||
if (onHighlightClick) {
|
// Always try to navigate if we have a reference - quote button should always work
|
||||||
onHighlightClick(highlight.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, navigate to the article that this highlight references
|
|
||||||
if (highlight.eventReference) {
|
if (highlight.eventReference) {
|
||||||
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
|
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
|
||||||
const parts = highlight.eventReference.split(':')
|
const parts = highlight.eventReference.split(':')
|
||||||
@@ -209,9 +205,14 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
openHighlights: true
|
openHighlights: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (highlight.urlReference) {
|
// If eventReference is just an event ID (not a coordinate), we can't navigate to it
|
||||||
|
// as we don't have enough info to construct the article URL
|
||||||
|
}
|
||||||
|
|
||||||
|
if (highlight.urlReference) {
|
||||||
// Navigate to external URL with highlight ID to trigger scroll
|
// Navigate to external URL with highlight ID to trigger scroll
|
||||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
|
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
|
||||||
state: {
|
state: {
|
||||||
@@ -219,16 +220,57 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
openHighlights: true
|
openHighlights: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we get here, there's no valid reference to navigate to
|
||||||
|
// This shouldn't happen for valid highlights, but we'll log it for debugging
|
||||||
|
console.warn('Cannot navigate to article: highlight has no valid eventReference or urlReference', highlight.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleItemClick = () => {
|
||||||
|
// If onHighlightClick is provided, use it (legacy behavior)
|
||||||
|
if (onHighlightClick) {
|
||||||
|
onHighlightClick(highlight.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, navigate to the article that this highlight references
|
||||||
|
navigateToArticle()
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHighlightLinks = () => {
|
const getHighlightLinks = () => {
|
||||||
// Encode the highlight event itself (kind 9802) as a nevent
|
// Encode the highlight event itself (kind 9802) as a nevent
|
||||||
// Get non-local relays for the hint
|
// Relay hint selection priority:
|
||||||
const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : []
|
// 1. Published relays (where we successfully published the event)
|
||||||
const relayHints = activeRelays.filter(r =>
|
// 2. Seen relays (where we observed the event)
|
||||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
// 3. Configured content relays (deterministic fallback)
|
||||||
).slice(0, 3) // Include up to 3 relay hints
|
// All candidates are deduplicated, filtered to content-capable remote relays, and limited to 3
|
||||||
|
|
||||||
|
const publishedRelays = highlight.publishedRelays || []
|
||||||
|
const seenOnRelays = highlight.seenOnRelays || []
|
||||||
|
|
||||||
|
// Determine base candidates: prefer published, then seen, then configured relays
|
||||||
|
let candidates: string[]
|
||||||
|
if (publishedRelays.length > 0) {
|
||||||
|
// Prefer published relays, but include seen relays as backup
|
||||||
|
candidates = Array.from(new Set([...publishedRelays, ...seenOnRelays]))
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
} else if (seenOnRelays.length > 0) {
|
||||||
|
candidates = seenOnRelays
|
||||||
|
} else {
|
||||||
|
// Fallback to deterministic configured content relays
|
||||||
|
const contentRelays = getContentRelays()
|
||||||
|
const fallbackRelays = getFallbackContentRelays()
|
||||||
|
candidates = Array.from(new Set([...contentRelays, ...fallbackRelays]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to content-capable remote relays (exclude local and non-content relays)
|
||||||
|
// Then take up to 3 for relay hints
|
||||||
|
const relayHints = candidates
|
||||||
|
.filter(url => !isLocalRelay(url))
|
||||||
|
.filter(url => isContentRelay(url))
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
const nevent = nip19.neventEncode({
|
const nevent = nip19.neventEncode({
|
||||||
id: highlight.id,
|
id: highlight.id,
|
||||||
@@ -434,6 +476,71 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
handleConfirmDelete()
|
handleConfirmDelete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Navigate to author's profile
|
||||||
|
const navigateToProfile = (tab?: 'highlights' | 'writings') => {
|
||||||
|
try {
|
||||||
|
const npub = nip19.npubEncode(highlight.pubkey)
|
||||||
|
const path = tab === 'writings' ? `/p/${npub}/writings` : `/p/${npub}`
|
||||||
|
navigate(path)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to encode npub for profile navigation:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAuthorClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigateToProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuViewProfile = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowMenu(false)
|
||||||
|
navigateToProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuGoToQuote = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowMenu(false)
|
||||||
|
|
||||||
|
if (onHighlightClick) {
|
||||||
|
onHighlightClick(highlight.id)
|
||||||
|
} else {
|
||||||
|
navigateToArticle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderHighlightText = () => {
|
||||||
|
const { content, context } = highlight
|
||||||
|
|
||||||
|
if (context && context.length > 0) {
|
||||||
|
const index = context.indexOf(content)
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
const before = context.slice(0, index)
|
||||||
|
const after = context.slice(index + content.length)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{before}
|
||||||
|
<span className="highlight-core">{content}</span>
|
||||||
|
{after}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: show context and the core highlight separately
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="highlight-context-prefix">{context}</span>
|
||||||
|
<br />
|
||||||
|
<span className="highlight-core">{content}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="highlight-core">{content}</span>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -483,15 +590,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
<CompactButton
|
<CompactButton
|
||||||
className="highlight-quote-button"
|
className="highlight-quote-button"
|
||||||
icon={faQuoteLeft}
|
icon={faQuoteLeft}
|
||||||
title="Quote"
|
title="Go to quote in article"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (onHighlightClick) {
|
||||||
|
onHighlightClick(highlight.id)
|
||||||
|
} else {
|
||||||
|
navigateToArticle()
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* relay indicator lives in footer for consistent padding/alignment */}
|
{/* relay indicator lives in footer for consistent padding/alignment */}
|
||||||
|
|
||||||
<div className="highlight-content">
|
<div className="highlight-content">
|
||||||
<blockquote className="highlight-text">
|
<blockquote
|
||||||
{highlight.content}
|
className="highlight-text"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (onHighlightClick) {
|
||||||
|
onHighlightClick(highlight.id)
|
||||||
|
} else {
|
||||||
|
navigateToArticle()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
title="Go to quote in article"
|
||||||
|
>
|
||||||
|
{renderHighlightText()}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
|
|
||||||
{showCitation && (
|
{showCitation && (
|
||||||
@@ -524,9 +653,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="highlight-author">
|
<CompactButton
|
||||||
|
className="highlight-author"
|
||||||
|
onClick={handleAuthorClick}
|
||||||
|
title="View profile"
|
||||||
|
>
|
||||||
{getUserDisplayName()}
|
{getUserDisplayName()}
|
||||||
</span>
|
</CompactButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="highlight-menu-wrapper" ref={menuRef}>
|
<div className="highlight-menu-wrapper" ref={menuRef}>
|
||||||
@@ -565,6 +698,20 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
|
|
||||||
{showMenu && (
|
{showMenu && (
|
||||||
<div className="highlight-menu">
|
<div className="highlight-menu">
|
||||||
|
<button
|
||||||
|
className="highlight-menu-item"
|
||||||
|
onClick={handleMenuGoToQuote}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faQuoteLeft} />
|
||||||
|
<span>Go to quote</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="highlight-menu-item"
|
||||||
|
onClick={handleMenuViewProfile}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUser} />
|
||||||
|
<span>View profile</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="highlight-menu-item"
|
className="highlight-menu-item"
|
||||||
onClick={handleOpenPortal}
|
onClick={handleOpenPortal}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faPenToSquare, faEllipsisH, faCopy, faShare, faExternalLinkAlt, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
@@ -9,6 +9,7 @@ import { HighlightItem } from './HighlightItem'
|
|||||||
import { BlogPostPreview } from '../services/exploreService'
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import AuthorCard from './AuthorCard'
|
import AuthorCard from './AuthorCard'
|
||||||
|
import CompactButton from './CompactButton'
|
||||||
import BlogPostCard from './BlogPostCard'
|
import BlogPostCard from './BlogPostCard'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||||
@@ -20,6 +21,7 @@ import { Hooks } from 'applesauce-react'
|
|||||||
import { readingProgressController } from '../services/readingProgressController'
|
import { readingProgressController } from '../services/readingProgressController'
|
||||||
import { writingsController } from '../services/writingsController'
|
import { writingsController } from '../services/writingsController'
|
||||||
import { highlightsController } from '../services/highlightsController'
|
import { highlightsController } from '../services/highlightsController'
|
||||||
|
import { getProfileUrl } from '../config/nostrGateways'
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -38,6 +40,8 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
|
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
const [showProfileMenu, setShowProfileMenu] = useState(false)
|
||||||
|
const profileMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Reading progress state (naddr -> progress 0-1)
|
// Reading progress state (naddr -> progress 0-1)
|
||||||
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
@@ -168,6 +172,68 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
const npub = nip19.npubEncode(pubkey)
|
const npub = nip19.npubEncode(pubkey)
|
||||||
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
|
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target as Node)) {
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showProfileMenu) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [showProfileMenu])
|
||||||
|
|
||||||
|
// Profile menu handlers
|
||||||
|
const handleMenuToggle = () => {
|
||||||
|
setShowProfileMenu(!showProfileMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyProfileLink = async () => {
|
||||||
|
try {
|
||||||
|
const borisUrl = `${window.location.origin}/p/${npub}`
|
||||||
|
await navigator.clipboard.writeText(borisUrl)
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Copy failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShareProfile = async () => {
|
||||||
|
try {
|
||||||
|
const borisUrl = `${window.location.origin}/p/${npub}`
|
||||||
|
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||||
|
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||||
|
title: 'Profile',
|
||||||
|
url: borisUrl
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(borisUrl)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Share failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenPortal = () => {
|
||||||
|
const portalUrl = getProfileUrl(npub)
|
||||||
|
window.open(portalUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenNative = () => {
|
||||||
|
const nativeUrl = `nostr:${npub}`
|
||||||
|
window.location.href = nativeUrl
|
||||||
|
setShowProfileMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'highlights':
|
case 'highlights':
|
||||||
@@ -236,7 +302,51 @@ const Profile: React.FC<ProfileProps> = ({
|
|||||||
pullPosition={pullPosition}
|
pullPosition={pullPosition}
|
||||||
/>
|
/>
|
||||||
<div className="explore-header">
|
<div className="explore-header">
|
||||||
<AuthorCard authorPubkey={pubkey} clickable={false} />
|
<div className="profile-header-wrapper">
|
||||||
|
<div className="profile-card-with-menu">
|
||||||
|
<AuthorCard authorPubkey={pubkey} clickable={false} />
|
||||||
|
<div className="profile-card-menu-wrapper" ref={profileMenuRef}>
|
||||||
|
<CompactButton
|
||||||
|
icon={faEllipsisH}
|
||||||
|
onClick={handleMenuToggle}
|
||||||
|
title="More options"
|
||||||
|
ariaLabel="Profile menu"
|
||||||
|
/>
|
||||||
|
{showProfileMenu && (
|
||||||
|
<div className="profile-card-menu">
|
||||||
|
<button
|
||||||
|
className="profile-card-menu-item"
|
||||||
|
onClick={handleCopyProfileLink}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
|
<span>Copy Link</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="profile-card-menu-item"
|
||||||
|
onClick={handleShareProfile}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faShare} />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="profile-card-menu-item"
|
||||||
|
onClick={handleOpenPortal}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open with njump</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="profile-card-menu-item"
|
||||||
|
onClick={handleOpenNative}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faMobileAlt} />
|
||||||
|
<span>Open with Native App</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="me-tabs">
|
<div className="me-tabs">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
ttsDetectContentLanguage: true,
|
ttsDetectContentLanguage: true,
|
||||||
ttsLanguageMode: 'content',
|
ttsLanguageMode: 'content',
|
||||||
ttsDefaultSpeed: 2.1,
|
ttsDefaultSpeed: 2.1,
|
||||||
|
linkColorDark: '#38bdf8',
|
||||||
|
linkColorLight: '#3b82f6',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import IconButton from '../IconButton'
|
|||||||
import ColorPicker from '../ColorPicker'
|
import ColorPicker from '../ColorPicker'
|
||||||
import FontSelector from '../FontSelector'
|
import FontSelector from '../FontSelector'
|
||||||
import { getFontFamily } from '../../utils/fontLoader'
|
import { getFontFamily } from '../../utils/fontLoader'
|
||||||
import { hexToRgb } from '../../utils/colorHelpers'
|
import { hexToRgb, LINK_COLORS_DARK, LINK_COLORS_LIGHT } from '../../utils/colorHelpers'
|
||||||
|
|
||||||
interface ReadingDisplaySettingsProps {
|
interface ReadingDisplaySettingsProps {
|
||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
@@ -14,6 +14,23 @@ interface ReadingDisplaySettingsProps {
|
|||||||
|
|
||||||
const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ settings, onUpdate }) => {
|
const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ settings, onUpdate }) => {
|
||||||
const previewFontFamily = getFontFamily(settings.readingFont || 'source-serif-4')
|
const previewFontFamily = getFontFamily(settings.readingFont || 'source-serif-4')
|
||||||
|
|
||||||
|
// Determine current effective theme for color palette selection
|
||||||
|
const currentTheme = settings.theme ?? 'system'
|
||||||
|
const isDark = currentTheme === 'dark' ||
|
||||||
|
(currentTheme === 'system' && (typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)').matches : true))
|
||||||
|
const linkColors = isDark ? LINK_COLORS_DARK : LINK_COLORS_LIGHT
|
||||||
|
const currentLinkColor = isDark
|
||||||
|
? (settings.linkColorDark || '#38bdf8')
|
||||||
|
: (settings.linkColorLight || '#3b82f6')
|
||||||
|
|
||||||
|
const handleLinkColorChange = (color: string) => {
|
||||||
|
if (isDark) {
|
||||||
|
onUpdate({ linkColorDark: color })
|
||||||
|
} else {
|
||||||
|
onUpdate({ linkColorLight: color })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
@@ -109,6 +126,17 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label className="setting-label">Link Color</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<ColorPicker
|
||||||
|
selectedColor={currentLinkColor}
|
||||||
|
onColorChange={handleLinkColorChange}
|
||||||
|
colors={linkColors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label className="setting-label">Font Size</label>
|
<label className="setting-label">Font Size</label>
|
||||||
<div className="setting-control">
|
<div className="setting-control">
|
||||||
@@ -179,14 +207,16 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
fontFamily: previewFontFamily,
|
fontFamily: previewFontFamily,
|
||||||
fontSize: `${settings.fontSize || 21}px`,
|
fontSize: `${settings.fontSize || 21}px`,
|
||||||
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'),
|
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'),
|
||||||
'--paragraph-alignment': settings.paragraphAlignment || 'justify'
|
'--paragraph-alignment': settings.paragraphAlignment || 'justify',
|
||||||
|
'--color-link': isDark
|
||||||
|
? (settings.linkColorDark || '#38bdf8')
|
||||||
|
: (settings.linkColorLight || '#3b82f6')
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<h3>The Quick Brown Fox</h3>
|
<h3>The Quick Brown Fox</h3>
|
||||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityMine !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityMine !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
||||||
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityFriends !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityFriends !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
||||||
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityNostrverse !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
|
<p>Totam rem aperiam, eaque ipsa quae ab illo <a href="/a/naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqq8ky6t5vdhkjm3dd9ej6arfd4jszh5rdq">inventore veritatis</a> et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityNostrverse !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
|
||||||
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Nostr gateway URLs for viewing events and profiles on the web
|
* Nostr gateway URLs for viewing events and profiles on the web
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const NOSTR_GATEWAY = 'https://nostr.at' as const
|
export const NOSTR_GATEWAY = 'https://njump.to' as const
|
||||||
export const SEARCH_PORTAL = 'https://ants.sh' as const
|
export const SEARCH_PORTAL = 'https://ants.sh' as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,7 +24,7 @@ export function getEventUrl(nevent: string): string {
|
|||||||
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
|
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
|
||||||
*/
|
*/
|
||||||
export function getNostrUrl(identifier: string): string {
|
export function getNostrUrl(identifier: string): string {
|
||||||
// nostr.at uses simple /{identifier} format for all types
|
// njump.to uses simple /{identifier} format for all types
|
||||||
return `${NOSTR_GATEWAY}/${identifier}`
|
return `${NOSTR_GATEWAY}/${identifier}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,101 @@
|
|||||||
|
import { normalizeRelayUrl } from '../utils/helpers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralized relay configuration
|
* Centralized relay configuration
|
||||||
* Single set of relays used throughout the application
|
* Single set of relays used throughout the application
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// All relays including local relays
|
export type RelayRole = 'local-cache' | 'default' | 'fallback' | 'non-content' | 'bunker'
|
||||||
export const RELAYS = [
|
|
||||||
'ws://localhost:10547',
|
export interface RelayConfig {
|
||||||
'ws://localhost:4869',
|
url: string
|
||||||
'wss://relay.nsec.app',
|
roles: RelayRole[]
|
||||||
'wss://relay.damus.io',
|
}
|
||||||
'wss://nos.lol',
|
|
||||||
'wss://relay.nostr.band',
|
/**
|
||||||
'wss://wot.dergigi.com',
|
* Central relay registry with role annotations
|
||||||
'wss://relay.snort.social',
|
*/
|
||||||
'wss://nostr-pub.wellorder.net',
|
const RELAY_CONFIGS: RelayConfig[] = [
|
||||||
'wss://purplepag.es',
|
{ url: 'ws://localhost:10547', roles: ['local-cache'] },
|
||||||
'wss://relay.primal.net',
|
{ url: 'ws://localhost:4869', roles: ['local-cache'] },
|
||||||
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87',
|
{ url: 'wss://relay.nsec.app', roles: ['default', 'non-content'] },
|
||||||
|
{ url: 'wss://relay.damus.io', roles: ['default', 'fallback'] },
|
||||||
|
{ url: 'wss://nos.lol', roles: ['default', 'fallback'] },
|
||||||
|
{ url: 'wss://relay.nostr.band', roles: ['default', 'fallback'] },
|
||||||
|
{ url: 'wss://wot.dergigi.com', roles: ['default'] },
|
||||||
|
{ url: 'wss://relay.snort.social', roles: ['default'] },
|
||||||
|
{ url: 'wss://nostr-pub.wellorder.net', roles: ['default'] },
|
||||||
|
{ url: 'wss://purplepag.es', roles: ['default'] },
|
||||||
|
{ url: 'wss://relay.primal.net', roles: ['default', 'fallback'] },
|
||||||
|
{ url: 'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87', roles: ['default'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all local cache relays (localhost relays)
|
||||||
|
*/
|
||||||
|
export function getLocalRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => config.roles.includes('local-cache'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all default relays (main public relays)
|
||||||
|
*/
|
||||||
|
export function getDefaultRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => config.roles.includes('default'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fallback content relays (last resort public relays for content fetching)
|
||||||
|
* These are reliable public relays that should be tried when other methods fail
|
||||||
|
*/
|
||||||
|
export function getFallbackContentRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => config.roles.includes('fallback'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relays suitable for content fetching (excludes non-content relays like auth/signer relays)
|
||||||
|
*/
|
||||||
|
export function getContentRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => !config.roles.includes('non-content'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relays that should NOT be used as content hints
|
||||||
|
*/
|
||||||
|
export function getNonContentRelays(): string[] {
|
||||||
|
return RELAY_CONFIGS
|
||||||
|
.filter(config => config.roles.includes('non-content'))
|
||||||
|
.map(config => config.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All relays including local relays (backwards compatibility)
|
||||||
|
*/
|
||||||
|
export const RELAYS = [
|
||||||
|
...getLocalRelays(),
|
||||||
|
...getDefaultRelays(),
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relays that should NOT be used as content hints (backwards compatibility)
|
||||||
|
*/
|
||||||
|
export const NON_CONTENT_RELAYS = getNonContentRelays()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a relay URL is suitable for use as a content hint
|
||||||
|
* Returns true for relays that are reasonable for posts/highlights
|
||||||
|
*/
|
||||||
|
export function isContentRelay(url: string): boolean {
|
||||||
|
const normalized = normalizeRelayUrl(url)
|
||||||
|
const nonContentRelays = getNonContentRelays().map(normalizeRelayUrl)
|
||||||
|
return !nonContentRelays.includes(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ interface PreviewData {
|
|||||||
published?: number
|
published?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NavigationState {
|
||||||
|
previewData?: PreviewData
|
||||||
|
articleCoordinate?: string
|
||||||
|
eventId?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface UseArticleLoaderProps {
|
interface UseArticleLoaderProps {
|
||||||
naddr: string | undefined
|
naddr: string | undefined
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
@@ -63,8 +69,11 @@ export function useArticleLoader({
|
|||||||
// Track in-flight request to prevent stale updates from previous naddr
|
// Track in-flight request to prevent stale updates from previous naddr
|
||||||
const currentRequestIdRef = useRef(0)
|
const currentRequestIdRef = useRef(0)
|
||||||
|
|
||||||
// Extract preview data from navigation state (from blog post cards)
|
// Extract navigation state (from blog post cards)
|
||||||
const previewData = (location.state as { previewData?: PreviewData })?.previewData
|
const navState = (location.state as NavigationState | null) || {}
|
||||||
|
const previewData = navState.previewData
|
||||||
|
const navArticleCoordinate = navState.articleCoordinate
|
||||||
|
const navEventId = navState.eventId
|
||||||
|
|
||||||
// Track the current article title for document title
|
// Track the current article title for document title
|
||||||
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
|
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
|
||||||
@@ -83,6 +92,179 @@ export function useArticleLoader({
|
|||||||
// This ensures images from previous articles don't flash briefly
|
// This ensures images from previous articles don't flash briefly
|
||||||
setReaderContent(undefined)
|
setReaderContent(undefined)
|
||||||
|
|
||||||
|
// FIRST: Check navigation state for article coordinate/eventId (from Explore)
|
||||||
|
// This allows immediate hydration when coming from Explore without refetching
|
||||||
|
let foundInNavState = false
|
||||||
|
if (eventStore && (navArticleCoordinate || navEventId)) {
|
||||||
|
try {
|
||||||
|
let storedEvent: NostrEvent | undefined
|
||||||
|
|
||||||
|
// Try coordinate first (most reliable for replaceable events)
|
||||||
|
if (navArticleCoordinate) {
|
||||||
|
storedEvent = eventStore.getEvent?.(navArticleCoordinate) as NostrEvent | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to eventId if coordinate lookup failed
|
||||||
|
if (!storedEvent && navEventId) {
|
||||||
|
// Note: eventStore.getEvent might not support eventId lookup directly
|
||||||
|
// We'll decode naddr to get coordinate as fallback
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type === 'naddr') {
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||||
|
storedEvent = eventStore.getEvent?.(coordinate) as NostrEvent | undefined
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore decode errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedEvent) {
|
||||||
|
foundInNavState = true
|
||||||
|
const title = Helpers.getArticleTitle(storedEvent) || previewData?.title || 'Untitled Article'
|
||||||
|
setCurrentTitle(title)
|
||||||
|
const image = Helpers.getArticleImage(storedEvent) || previewData?.image
|
||||||
|
const summary = Helpers.getArticleSummary(storedEvent) || previewData?.summary
|
||||||
|
const published = Helpers.getArticlePublished(storedEvent) || previewData?.published
|
||||||
|
setReaderContent({
|
||||||
|
title,
|
||||||
|
markdown: storedEvent.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
url: `nostr:${naddr}`
|
||||||
|
})
|
||||||
|
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
|
||||||
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
|
setCurrentArticleEventId(storedEvent.id)
|
||||||
|
setCurrentArticle?.(storedEvent)
|
||||||
|
setReaderLoading(false)
|
||||||
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
|
setIsCollapsed(true)
|
||||||
|
|
||||||
|
// Preload image if available
|
||||||
|
if (image) {
|
||||||
|
preloadImage(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch highlights in background if relayPool is available
|
||||||
|
if (relayPool) {
|
||||||
|
const coord = dTag ? `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` : undefined
|
||||||
|
const eventId = storedEvent.id
|
||||||
|
|
||||||
|
if (coord && eventId) {
|
||||||
|
setHighlightsLoading(true)
|
||||||
|
fetchHighlightsForArticle(
|
||||||
|
relayPool,
|
||||||
|
coord,
|
||||||
|
eventId,
|
||||||
|
(highlight) => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
setHighlights((prev: Highlight[]) => {
|
||||||
|
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||||
|
const next = [highlight, ...prev]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
false,
|
||||||
|
eventStore || undefined
|
||||||
|
).then(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start background query to check for newer replaceable version
|
||||||
|
// but don't block UI - we already have content
|
||||||
|
if (relayPool) {
|
||||||
|
const backgroundRequestId = ++currentRequestIdRef.current
|
||||||
|
const originalCreatedAt = storedEvent.created_at
|
||||||
|
|
||||||
|
// Fire and forget background fetch
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type !== 'naddr') return
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
const filter = {
|
||||||
|
kinds: [pointer.kind],
|
||||||
|
authors: [pointer.pubkey],
|
||||||
|
'#d': [pointer.identifier]
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryEvents(relayPool, filter, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (!mountedRef.current || currentRequestIdRef.current !== backgroundRequestId) return
|
||||||
|
|
||||||
|
// Store in event store
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
eventStore?.add?.(evt as unknown as any)
|
||||||
|
} catch {
|
||||||
|
// Ignore store errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update if this is a newer version than what we loaded
|
||||||
|
if (evt.created_at > originalCreatedAt) {
|
||||||
|
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
|
||||||
|
const image = Helpers.getArticleImage(evt)
|
||||||
|
const summary = Helpers.getArticleSummary(evt)
|
||||||
|
const published = Helpers.getArticlePublished(evt)
|
||||||
|
|
||||||
|
setCurrentTitle(title)
|
||||||
|
setReaderContent({
|
||||||
|
title,
|
||||||
|
markdown: evt.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
url: `nostr:${naddr}`
|
||||||
|
})
|
||||||
|
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const articleCoordinate = `${evt.kind}:${evt.pubkey}:${dTag}`
|
||||||
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
|
setCurrentArticleEventId(evt.id)
|
||||||
|
setCurrentArticle?.(evt)
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
const articleContent = {
|
||||||
|
title,
|
||||||
|
markdown: evt.content,
|
||||||
|
image,
|
||||||
|
summary,
|
||||||
|
published,
|
||||||
|
author: evt.pubkey,
|
||||||
|
event: evt
|
||||||
|
}
|
||||||
|
saveToCache(naddr, articleContent, settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// Silently ignore background fetch errors - we already have content
|
||||||
|
console.warn('[article-loader] Background fetch failed:', err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return early - we have content from navigation state
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If navigation state lookup fails, fall through to cache/EventStore
|
||||||
|
console.warn('[article-loader] Navigation state lookup failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Synchronously check cache sources BEFORE checking relayPool
|
// Synchronously check cache sources BEFORE checking relayPool
|
||||||
// This prevents showing loading skeletons when content is immediately available
|
// This prevents showing loading skeletons when content is immediately available
|
||||||
// and fixes the race condition where relayPool isn't ready yet
|
// and fixes the race condition where relayPool isn't ready yet
|
||||||
@@ -173,7 +355,7 @@ export function useArticleLoader({
|
|||||||
|
|
||||||
// Check EventStore synchronously (also doesn't need relayPool)
|
// Check EventStore synchronously (also doesn't need relayPool)
|
||||||
let foundInEventStore = false
|
let foundInEventStore = false
|
||||||
if (eventStore && !foundInCache) {
|
if (eventStore && !foundInCache && !foundInNavState) {
|
||||||
try {
|
try {
|
||||||
// Decode naddr to get the coordinate
|
// Decode naddr to get the coordinate
|
||||||
const decoded = nip19.decode(naddr)
|
const decoded = nip19.decode(naddr)
|
||||||
@@ -251,7 +433,7 @@ export function useArticleLoader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only return early if we have no content AND no relayPool to fetch from
|
// Only return early if we have no content AND no relayPool to fetch from
|
||||||
if (!relayPool && !foundInCache && !foundInEventStore) {
|
if (!relayPool && !foundInCache && !foundInEventStore && !foundInNavState) {
|
||||||
setReaderLoading(true)
|
setReaderLoading(true)
|
||||||
setReaderContent(undefined)
|
setReaderContent(undefined)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||||
|
|
||||||
|
// Set link colors for dark and light themes separately
|
||||||
|
const darkLinkColor = settings.linkColorDark || '#38bdf8'
|
||||||
|
const lightLinkColor = settings.linkColorLight || '#3b82f6'
|
||||||
|
root.setProperty('--color-link-dark', darkLinkColor)
|
||||||
|
root.setProperty('--color-link-light', lightLinkColor)
|
||||||
|
|
||||||
// Set paragraph alignment
|
// Set paragraph alignment
|
||||||
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { nip19 } from 'nostr-tools'
|
|||||||
import { AddressPointer } from 'nostr-tools/nip19'
|
import { AddressPointer } from 'nostr-tools/nip19'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { RELAYS } from '../config/relays'
|
import { getContentRelays, getFallbackContentRelays, isContentRelay } from '../config/relays'
|
||||||
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
|
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
|
||||||
import { merge, toArray as rxToArray } from 'rxjs'
|
import { merge, toArray as rxToArray } from 'rxjs'
|
||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
@@ -138,13 +138,6 @@ export async function fetchArticleByNaddr(
|
|||||||
|
|
||||||
const pointer = decoded.data as AddressPointer
|
const pointer = decoded.data as AddressPointer
|
||||||
|
|
||||||
// Define relays to query - use union of relay hints from naddr and configured relays
|
|
||||||
// This avoids failures when naddr contains stale/unreachable relay hints
|
|
||||||
const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : []
|
|
||||||
const baseRelays = Array.from(new Set<string>([...hintedRelays, ...RELAYS]))
|
|
||||||
const orderedRelays = prioritizeLocalRelays(baseRelays)
|
|
||||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
|
||||||
|
|
||||||
// Fetch the article event
|
// Fetch the article event
|
||||||
const filter = {
|
const filter = {
|
||||||
kinds: [pointer.kind],
|
kinds: [pointer.kind],
|
||||||
@@ -152,24 +145,45 @@ export async function fetchArticleByNaddr(
|
|||||||
'#d': [pointer.identifier]
|
'#d': [pointer.identifier]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parallel local+remote, stream immediate, collect up to first from each
|
let events: NostrEvent[] = []
|
||||||
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
|
|
||||||
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
|
|
||||||
let events = collected as NostrEvent[]
|
|
||||||
|
|
||||||
// Fallback: if nothing found, try a second round against a set of reliable public relays
|
// Build unified relay set: hints + configured content relays
|
||||||
|
// Filter hinted relays to only content-capable relays
|
||||||
|
const hintedRelays = (pointer.relays && pointer.relays.length > 0)
|
||||||
|
? pointer.relays.filter(isContentRelay)
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Get configured content relays
|
||||||
|
const contentRelays = getContentRelays()
|
||||||
|
|
||||||
|
// Union of hinted and configured relays (deduplicated)
|
||||||
|
const unifiedRelays = Array.from(new Set([...hintedRelays, ...contentRelays]))
|
||||||
|
|
||||||
|
if (unifiedRelays.length > 0) {
|
||||||
|
const orderedUnified = prioritizeLocalRelays(unifiedRelays)
|
||||||
|
const { local: localUnified, remote: remoteUnified } = partitionRelays(orderedUnified)
|
||||||
|
|
||||||
|
const { local$, remote$ } = createParallelReqStreams(
|
||||||
|
relayPool,
|
||||||
|
localUnified,
|
||||||
|
remoteUnified,
|
||||||
|
filter,
|
||||||
|
1200,
|
||||||
|
6000
|
||||||
|
)
|
||||||
|
const collected = await lastValueFrom(
|
||||||
|
merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())
|
||||||
|
)
|
||||||
|
events = collected as NostrEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: try fallback content relays (most reliable public relays)
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
const reliableRelays = Array.from(new Set<string>([
|
const fallbackRelays = getFallbackContentRelays()
|
||||||
'wss://relay.nostr.band',
|
|
||||||
'wss://relay.primal.net',
|
|
||||||
'wss://relay.damus.io',
|
|
||||||
'wss://nos.lol',
|
|
||||||
...remoteRelays // keep any configured remote relays
|
|
||||||
]))
|
|
||||||
const { remote$: fallback$ } = createParallelReqStreams(
|
const { remote$: fallback$ } = createParallelReqStreams(
|
||||||
relayPool,
|
relayPool,
|
||||||
[], // no local
|
[], // no local for fallback
|
||||||
reliableRelays,
|
fallbackRelays,
|
||||||
filter,
|
filter,
|
||||||
1500,
|
1500,
|
||||||
12000
|
12000
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { normalizeRelayUrl } from '../utils/helpers'
|
||||||
|
|
||||||
export interface UserRelayInfo {
|
export interface UserRelayInfo {
|
||||||
url: string
|
url: string
|
||||||
@@ -144,35 +145,55 @@ export function computeRelaySet(params: {
|
|||||||
alwaysIncludeLocal
|
alwaysIncludeLocal
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
|
// Normalize all URLs for consistent comparison and deduplication
|
||||||
|
const normalizedBlocked = new Set(blocked.map(normalizeRelayUrl))
|
||||||
|
const normalizedLocal = new Set(alwaysIncludeLocal.map(normalizeRelayUrl))
|
||||||
|
|
||||||
const relaySet = new Set<string>()
|
const relaySet = new Set<string>()
|
||||||
const blockedSet = new Set(blocked)
|
const normalizedRelaySet = new Set<string>()
|
||||||
|
|
||||||
// Helper to check if relay should be included
|
// Helper to check if relay should be included (using normalized URLs)
|
||||||
const shouldInclude = (url: string): boolean => {
|
const shouldInclude = (normalizedUrl: string): boolean => {
|
||||||
// Always include local relays
|
// Always include local relays
|
||||||
if (alwaysIncludeLocal.includes(url)) return true
|
if (normalizedLocal.has(normalizedUrl)) return true
|
||||||
// Otherwise check if blocked
|
// Otherwise check if blocked
|
||||||
return !blockedSet.has(url)
|
return !normalizedBlocked.has(normalizedUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add hardcoded relays
|
// Add hardcoded relays (normalized)
|
||||||
for (const url of hardcoded) {
|
for (const url of hardcoded) {
|
||||||
if (shouldInclude(url)) relaySet.add(url)
|
const normalized = normalizeRelayUrl(url)
|
||||||
|
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
|
||||||
|
normalizedRelaySet.add(normalized)
|
||||||
|
relaySet.add(url) // Keep original URL for output
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add bunker relays
|
// Add bunker relays (normalized)
|
||||||
for (const url of bunker) {
|
for (const url of bunker) {
|
||||||
if (shouldInclude(url)) relaySet.add(url)
|
const normalized = normalizeRelayUrl(url)
|
||||||
|
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
|
||||||
|
normalizedRelaySet.add(normalized)
|
||||||
|
relaySet.add(url) // Keep original URL for output
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user relays (treating 'both' and 'read' as applicable for queries)
|
// Add user relays (normalized)
|
||||||
for (const relay of userList) {
|
for (const relay of userList) {
|
||||||
if (shouldInclude(relay.url)) relaySet.add(relay.url)
|
const normalized = normalizeRelayUrl(relay.url)
|
||||||
|
if (shouldInclude(normalized) && !normalizedRelaySet.has(normalized)) {
|
||||||
|
normalizedRelaySet.add(normalized)
|
||||||
|
relaySet.add(relay.url) // Keep original URL for output
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always ensure local relays are present
|
// Always ensure local relays are present (normalized check)
|
||||||
for (const url of alwaysIncludeLocal) {
|
for (const url of alwaysIncludeLocal) {
|
||||||
relaySet.add(url)
|
const normalized = normalizeRelayUrl(url)
|
||||||
|
if (!normalizedRelaySet.has(normalized)) {
|
||||||
|
normalizedRelaySet.add(normalized)
|
||||||
|
relaySet.add(url) // Keep original URL for output
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(relaySet)
|
return Array.from(relaySet)
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { prioritizeLocalRelays } from '../utils/helpers'
|
import { prioritizeLocalRelays, normalizeRelayUrl } from '../utils/helpers'
|
||||||
|
import { getLocalRelays, getFallbackContentRelays } from '../config/relays'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Local relays that are always included
|
* Local relays that are always included
|
||||||
*/
|
*/
|
||||||
export const ALWAYS_LOCAL_RELAYS = [
|
export const ALWAYS_LOCAL_RELAYS = getLocalRelays()
|
||||||
'ws://localhost:10547',
|
|
||||||
'ws://localhost:4869'
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hardcoded relays that are always included
|
* Hardcoded relays that are always included (minimal reliable set)
|
||||||
|
* Derived from RELAY_CONFIGS fallback relays
|
||||||
*/
|
*/
|
||||||
export const HARDCODED_RELAYS = [
|
export const HARDCODED_RELAYS = getFallbackContentRelays()
|
||||||
'wss://relay.nostr.band'
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets active relay URLs from the relay pool
|
* Gets active relay URLs from the relay pool
|
||||||
@@ -24,76 +21,84 @@ export function getActiveRelayUrls(relayPool: RelayPool): string[] {
|
|||||||
return prioritizeLocalRelays(urls)
|
return prioritizeLocalRelays(urls)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface RelaySetChangeSummary {
|
||||||
* Normalizes a relay URL to match what applesauce-relay stores internally
|
added: string[]
|
||||||
* Adds trailing slash for URLs without a path
|
removed: string[]
|
||||||
*/
|
|
||||||
function normalizeRelayUrl(url: string): string {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url)
|
|
||||||
// If the pathname is empty or just "/", ensure it ends with "/"
|
|
||||||
if (parsed.pathname === '' || parsed.pathname === '/') {
|
|
||||||
return url.endsWith('/') ? url : url + '/'
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
} catch {
|
|
||||||
// If URL parsing fails, return as-is
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies a new relay set to the pool: adds missing relays, removes extras
|
* Applies a new relay set to the pool: adds missing relays, removes extras
|
||||||
|
* Always preserves local relays even if not in finalUrls
|
||||||
|
* @returns Summary of changes for debugging
|
||||||
*/
|
*/
|
||||||
export function applyRelaySetToPool(
|
export function applyRelaySetToPool(
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
finalUrls: string[]
|
finalUrls: string[],
|
||||||
): void {
|
options?: { preserveAlwaysLocal?: boolean }
|
||||||
// Normalize all URLs to match pool's internal format
|
): RelaySetChangeSummary {
|
||||||
const currentUrls = new Set(Array.from(relayPool.relays.keys()))
|
const preserveLocal = options?.preserveAlwaysLocal !== false // default true
|
||||||
const normalizedTargetUrls = new Set(finalUrls.map(normalizeRelayUrl))
|
|
||||||
|
|
||||||
|
|
||||||
|
// Ensure local relays are always included
|
||||||
// Add new relays (use original URLs for adding, not normalized)
|
const urlsWithLocal = preserveLocal
|
||||||
const toAdd = finalUrls.filter(url => !currentUrls.has(normalizeRelayUrl(url)))
|
? Array.from(new Set([...finalUrls, ...ALWAYS_LOCAL_RELAYS]))
|
||||||
|
: finalUrls
|
||||||
|
|
||||||
if (toAdd.length > 0) {
|
// Normalize all URLs consistently for comparison
|
||||||
relayPool.group(toAdd)
|
const normalizedCurrent = new Set(
|
||||||
|
Array.from(relayPool.relays.keys()).map(normalizeRelayUrl)
|
||||||
|
)
|
||||||
|
const normalizedTarget = new Set(urlsWithLocal.map(normalizeRelayUrl))
|
||||||
|
|
||||||
|
// Map normalized URLs back to original for adding
|
||||||
|
const normalizedToOriginal = new Map<string, string>()
|
||||||
|
for (const url of urlsWithLocal) {
|
||||||
|
normalizedToOriginal.set(normalizeRelayUrl(url), url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove relays not in target (but always keep local relays)
|
// Find relays to add (not in current pool)
|
||||||
|
const toAdd: string[] = []
|
||||||
|
for (const normalizedUrl of normalizedTarget) {
|
||||||
|
if (!normalizedCurrent.has(normalizedUrl)) {
|
||||||
|
const originalUrl = normalizedToOriginal.get(normalizedUrl) || normalizedUrl
|
||||||
|
toAdd.push(originalUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find relays to remove (not in target, but preserve local relays)
|
||||||
|
const normalizedLocal = new Set(ALWAYS_LOCAL_RELAYS.map(normalizeRelayUrl))
|
||||||
const toRemove: string[] = []
|
const toRemove: string[] = []
|
||||||
for (const url of currentUrls) {
|
for (const currentUrl of relayPool.relays.keys()) {
|
||||||
// Check if this normalized URL is in the target set
|
const normalizedCurrentUrl = normalizeRelayUrl(currentUrl)
|
||||||
if (!normalizedTargetUrls.has(url)) {
|
if (!normalizedTarget.has(normalizedCurrentUrl)) {
|
||||||
// Also check if it's a local relay (check both normalized and original forms)
|
// Always preserve local relays
|
||||||
const isLocal = ALWAYS_LOCAL_RELAYS.some(localUrl =>
|
if (!preserveLocal || !normalizedLocal.has(normalizedCurrentUrl)) {
|
||||||
normalizeRelayUrl(localUrl) === url || localUrl === url
|
toRemove.push(currentUrl)
|
||||||
)
|
|
||||||
if (!isLocal) {
|
|
||||||
toRemove.push(url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply changes
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
relayPool.group(toAdd)
|
||||||
|
}
|
||||||
|
|
||||||
for (const url of toRemove) {
|
for (const url of toRemove) {
|
||||||
const relay = relayPool.relays.get(url)
|
const relay = relayPool.relays.get(url)
|
||||||
if (relay) {
|
if (relay) {
|
||||||
try {
|
try {
|
||||||
// Only close if relay is actually connected or attempting to connect
|
|
||||||
// This helps avoid WebSocket warnings for connections that never started
|
|
||||||
relay.close()
|
relay.close()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Suppress errors when closing relays that haven't fully connected yet
|
// Suppress errors when closing relays that haven't fully connected yet
|
||||||
// This can happen when switching relay sets before connections establish
|
|
||||||
// Silently ignore
|
|
||||||
}
|
}
|
||||||
relayPool.relays.delete(url)
|
relayPool.relays.delete(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return summary for debugging (useful for understanding relay churn)
|
||||||
|
if (import.meta.env.DEV && (toAdd.length > 0 || toRemove.length > 0)) {
|
||||||
|
console.debug('[relay-pool] Changes:', { added: toAdd, removed: toRemove })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { added: toAdd, removed: toRemove }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ export interface UserSettings {
|
|||||||
ttsLanguageMode?: 'system' | 'content' | string // default: 'content', can also be language code like 'en', 'es', etc.
|
ttsLanguageMode?: 'system' | 'content' | string // default: 'content', can also be language code like 'en', 'es', etc.
|
||||||
// Text-to-Speech settings
|
// Text-to-Speech settings
|
||||||
ttsDefaultSpeed?: number // default: 2.1
|
ttsDefaultSpeed?: number // default: 2.1
|
||||||
|
// Link color for article content (theme-specific)
|
||||||
|
linkColorDark?: string // default: #38bdf8 (sky-400)
|
||||||
|
linkColorLight?: string // default: #3b82f6 (blue-500)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
--color-text-muted: #71717a; /* zinc-500 */
|
--color-text-muted: #71717a; /* zinc-500 */
|
||||||
--color-primary: #6366f1; /* indigo-500 */
|
--color-primary: #6366f1; /* indigo-500 */
|
||||||
--color-primary-hover: #4f46e5; /* indigo-600 */
|
--color-primary-hover: #4f46e5; /* indigo-600 */
|
||||||
|
--color-link: var(--color-link-dark, #38bdf8); /* sky-400 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme */
|
/* Light theme */
|
||||||
@@ -73,6 +74,7 @@
|
|||||||
--color-text-muted: #6b7280; /* gray-500 */
|
--color-text-muted: #6b7280; /* gray-500 */
|
||||||
--color-primary: #4f46e5; /* indigo-600 */
|
--color-primary: #4f46e5; /* indigo-600 */
|
||||||
--color-primary-hover: #4338ca; /* indigo-700 */
|
--color-primary-hover: #4338ca; /* indigo-700 */
|
||||||
|
--color-link: var(--color-link-light, #3b82f6); /* blue-500 */
|
||||||
|
|
||||||
/* Highlight colors for light theme - use same Tailwind colors */
|
/* Highlight colors for light theme - use same Tailwind colors */
|
||||||
--highlight-color-mine: #fde047; /* yellow-300 */
|
--highlight-color-mine: #fde047; /* yellow-300 */
|
||||||
@@ -97,6 +99,7 @@
|
|||||||
--color-text-muted: #71717a;
|
--color-text-muted: #71717a;
|
||||||
--color-primary: #6366f1;
|
--color-primary: #6366f1;
|
||||||
--color-primary-hover: #4f46e5;
|
--color-primary-hover: #4f46e5;
|
||||||
|
--color-link: var(--color-link-dark, #38bdf8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +115,7 @@
|
|||||||
--color-text-muted: #6b7280;
|
--color-text-muted: #6b7280;
|
||||||
--color-primary: #4f46e5;
|
--color-primary: #4f46e5;
|
||||||
--color-primary-hover: #4338ca;
|
--color-primary-hover: #4338ca;
|
||||||
|
--color-link: var(--color-link-light, #3b82f6);
|
||||||
|
|
||||||
/* Standard highlight colors */
|
/* Standard highlight colors */
|
||||||
--highlight-color-mine: #fde047;
|
--highlight-color-mine: #fde047;
|
||||||
|
|||||||
@@ -263,7 +263,12 @@
|
|||||||
.large-read-button:hover { background: var(--color-primary-hover); }
|
.large-read-button:hover { background: var(--color-primary-hover); }
|
||||||
|
|
||||||
/* Blog cards (Explore) */
|
/* Blog cards (Explore) */
|
||||||
.explore-container { padding: 2rem; max-width: 1400px; margin: 0 auto; min-height: 100vh; }
|
.explore-container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: var(--main-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
.explore-header { text-align: center; margin-bottom: 3rem; }
|
.explore-header { text-align: center; margin-bottom: 3rem; }
|
||||||
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; justify-content: center; gap: 1rem; }
|
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; justify-content: center; gap: 1rem; }
|
||||||
.explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; }
|
.explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; }
|
||||||
@@ -272,7 +277,15 @@
|
|||||||
.explore-loading { min-height: 0; padding: 0.25rem 0; }
|
.explore-loading { min-height: 0; padding: 0.25rem 0; }
|
||||||
.explore-error { color: rgb(239 68 68); /* red-500 */ }
|
.explore-error { color: rgb(239 68 68); /* red-500 */ }
|
||||||
.explore-empty { color: var(--color-text-secondary); }
|
.explore-empty { color: var(--color-text-secondary); }
|
||||||
.explore-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; margin-top: 2rem; }
|
.explore-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.explore-grid.single-column {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.blog-post-card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
|
.blog-post-card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
|
||||||
.blog-post-card:hover { border-color: var(--color-primary); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
|
.blog-post-card:hover { border-color: var(--color-primary); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
|
||||||
.blog-post-card.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #fde047) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #fde047) 25%, transparent); }
|
.blog-post-card.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #fde047) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #fde047) 25%, transparent); }
|
||||||
|
|||||||
@@ -43,6 +43,13 @@
|
|||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
text-align: var(--paragraph-alignment, justify);
|
text-align: var(--paragraph-alignment, justify);
|
||||||
}
|
}
|
||||||
|
.preview-content a {
|
||||||
|
color: var(--color-link);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.preview-content a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
.setting-select { width: 100%; padding: 0.5rem 1.75rem 0.5rem 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
|
.setting-select { width: 100%; padding: 0.5rem 1.75rem 0.5rem 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
|
||||||
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
|
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
|
||||||
.setting-select:focus { outline: none; border-color: var(--color-primary); }
|
.setting-select:focus { outline: none; border-color: var(--color-primary); }
|
||||||
|
|||||||
@@ -73,7 +73,8 @@
|
|||||||
|
|
||||||
/* Align highlight list width with profile card width on /my */
|
/* Align highlight list width with profile card width on /my */
|
||||||
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
||||||
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
|
.explore-header .profile-header-wrapper { max-width: 600px; margin: 0 auto; width: 100%; }
|
||||||
|
.explore-header .author-card { max-width: none; margin: 0; width: auto; flex: 1; }
|
||||||
|
|
||||||
/* Hide tab labels on mobile to save space */
|
/* Hide tab labels on mobile to save space */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -10,6 +10,71 @@
|
|||||||
.author-card-name { font-size: 1rem; font-weight: 600; color: var(--color-text); margin-bottom: 0.5rem; text-align: left; }
|
.author-card-name { font-size: 1rem; font-weight: 600; color: var(--color-text); margin-bottom: 0.5rem; text-align: left; }
|
||||||
.author-card-bio { font-size: 0.9rem; color: var(--color-text-secondary); line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
|
.author-card-bio { font-size: 0.9rem; color: var(--color-text-secondary); line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
|
||||||
|
|
||||||
|
/* Profile header */
|
||||||
|
.profile-header-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove horizontal padding when inside explore-header to match tabs width */
|
||||||
|
.explore-header .profile-header-wrapper {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-with-menu {
|
||||||
|
position: relative;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile card menu - inside card, bottom-right */
|
||||||
|
.profile-card-menu-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
right: 1.25rem;
|
||||||
|
top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
min-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-menu-item:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-menu-item svg {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.author-card-container {
|
.author-card-container {
|
||||||
padding: 1.5rem 1rem;
|
padding: 1.5rem 1rem;
|
||||||
@@ -26,5 +91,13 @@
|
|||||||
.author-card-avatar svg { font-size: 2rem; }
|
.author-card-avatar svg { font-size: 2rem; }
|
||||||
.author-card-name { font-size: 0.95rem; }
|
.author-card-name { font-size: 0.95rem; }
|
||||||
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
|
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
|
||||||
}
|
|
||||||
|
|
||||||
|
.profile-header-wrapper {
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-menu-wrapper {
|
||||||
|
right: 1rem;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
font-size: 2.25rem; /* text-4xl */
|
font-size: 2.25rem; /* text-4xl */
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin-top: 2rem;
|
margin-top: 5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
font-size: 1.875rem; /* text-3xl */
|
font-size: 1.875rem; /* text-3xl */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
margin-top: 1.75rem;
|
margin-top: 4.5rem;
|
||||||
margin-bottom: 0.875rem;
|
margin-bottom: 0.875rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
font-size: 1.5rem; /* text-2xl */
|
font-size: 1.5rem; /* text-2xl */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin-top: 1.5rem;
|
margin-top: 4rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
font-size: 1.25rem; /* text-xl */
|
font-size: 1.25rem; /* text-xl */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin-top: 1.25rem;
|
margin-top: 3.5rem;
|
||||||
margin-bottom: 0.625rem;
|
margin-bottom: 0.625rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
font-size: 1.125rem; /* text-lg */
|
font-size: 1.125rem; /* text-lg */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin-top: 1rem;
|
margin-top: 3rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
@@ -111,12 +111,13 @@
|
|||||||
font-size: 1rem; /* text-base */
|
font-size: 1rem; /* text-base */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin-top: 1rem;
|
margin-top: 3rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.reader-markdown p { margin: 0.5rem 0; }
|
.reader-markdown p { margin: 1.5rem 0; }
|
||||||
.reader-html p, .reader-html div, .reader-html span, .reader-html li, .reader-html td, .reader-html th { font-size: 1em !important; }
|
.reader-html p { margin: 1.5rem 0; }
|
||||||
|
.reader-html div, .reader-html span, .reader-html li, .reader-html td, .reader-html th { font-size: 1em !important; }
|
||||||
/* Lists */
|
/* Lists */
|
||||||
.reader-markdown ul, .reader-html ul {
|
.reader-markdown ul, .reader-html ul {
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
@@ -159,7 +160,7 @@
|
|||||||
opacity: 0.69;
|
opacity: 0.69;
|
||||||
margin: 2.5rem 0;
|
margin: 2.5rem 0;
|
||||||
}
|
}
|
||||||
.reader-markdown a { color: var(--color-primary); text-decoration: none; }
|
.reader-markdown a { color: var(--color-link); text-decoration: none; }
|
||||||
.reader-markdown a:hover { text-decoration: underline; }
|
.reader-markdown a:hover { text-decoration: underline; }
|
||||||
.reader-markdown code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
.reader-markdown code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||||
.reader-markdown pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; line-height: 1.5; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
.reader-markdown pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; line-height: 1.5; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||||
@@ -170,6 +171,49 @@
|
|||||||
.reader-html pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
.reader-html pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||||
.reader-html code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
.reader-html code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||||
.reader-html pre code { background: transparent; border: none; padding: 0; display: block; }
|
.reader-html pre code { background: transparent; border: none; padding: 0; display: block; }
|
||||||
|
/* Tables - subtle styling that matches the app theme */
|
||||||
|
.reader-markdown table, .reader-html table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
.reader-markdown thead, .reader-html thead {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
.reader-markdown th, .reader-html th {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
border-bottom: 2px solid var(--color-border);
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
.reader-markdown td, .reader-html td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
color: var(--color-text);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.reader-markdown tbody tr:last-child td, .reader-html tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
/* Subtle row striping for better readability */
|
||||||
|
.reader-markdown tbody tr:nth-child(even), .reader-html tbody tr:nth-child(even) {
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
}
|
||||||
|
/* Table alignment support */
|
||||||
|
.reader-markdown th[align="center"], .reader-html th[align="center"],
|
||||||
|
.reader-markdown td[align="center"], .reader-html td[align="center"] {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.reader-markdown th[align="right"], .reader-html th[align="right"],
|
||||||
|
.reader-markdown td[align="right"], .reader-html td[align="right"] {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
/* Mobile: prevent code blocks from causing horizontal overflow */
|
/* Mobile: prevent code blocks from causing horizontal overflow */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.reader-markdown pre, .reader-html pre {
|
.reader-markdown pre, .reader-html pre {
|
||||||
@@ -190,6 +234,12 @@
|
|||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
/* Reduce padding on mobile for better fit */
|
||||||
|
.reader-markdown table td, .reader-html table td,
|
||||||
|
.reader-markdown table th, .reader-html table th {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-markdown img, .reader-html img {
|
.reader-markdown img, .reader-html img {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
.highlights-empty svg { color: var(--color-text-muted); margin-bottom: 0.5rem; }
|
.highlights-empty svg { color: var(--color-text-muted); margin-bottom: 0.5rem; }
|
||||||
.empty-hint { font-size: 0.875rem; color: var(--color-text-muted); margin-top: 0.5rem; }
|
.empty-hint { font-size: 0.875rem; color: var(--color-text-muted); margin-top: 0.5rem; }
|
||||||
|
|
||||||
.highlights-list { overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
.highlights-list { overflow-y: auto; padding: 1rem; padding-bottom: 10rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
.highlight-item { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
|
.highlight-item { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
|
||||||
.highlight-item:hover { border-color: var(--color-primary); }
|
.highlight-item:hover { border-color: var(--color-primary); }
|
||||||
.highlight-item.selected { border-color: var(--color-primary); background: var(--color-bg-elevated); box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
|
.highlight-item.selected { border-color: var(--color-primary); background: var(--color-bg-elevated); box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
|
||||||
@@ -149,7 +149,40 @@
|
|||||||
.highlight-item.level-nostrverse .highlight-quote-icon { color: var(--highlight-color-nostrverse, #9333ea); }
|
.highlight-item.level-nostrverse .highlight-quote-icon { color: var(--highlight-color-nostrverse, #9333ea); }
|
||||||
|
|
||||||
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 2.25rem 0.75rem 2.5rem; }
|
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 2.25rem 0.75rem 2.5rem; }
|
||||||
.highlight-text { margin: 0; padding: 0 0 0 1.25rem; font-style: italic; color: var(--color-text); line-height: 1.6; border-left: none; font-size: 0.95rem; }
|
.highlight-text {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 1.25rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
border-left: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
/* Aggressive wrapping for long words/URLs inside highlights */
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
.highlight-core {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color, #fde047) 35%, transparent);
|
||||||
|
padding: 0 0.1em;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
.highlight-item.level-mine .highlight-core {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-mine, #fde047) 40%, transparent);
|
||||||
|
}
|
||||||
|
.highlight-item.level-friends .highlight-core {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 35%, transparent);
|
||||||
|
}
|
||||||
|
.highlight-item.level-nostrverse .highlight-core {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 35%, transparent);
|
||||||
|
}
|
||||||
|
.highlight-context-prefix {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
.highlight-citation { margin-left: 1.25rem; font-size: 0.8rem; color: var(--color-text-secondary); font-style: normal; padding-top: 0.25rem; }
|
.highlight-citation { margin-left: 1.25rem; font-size: 0.8rem; color: var(--color-text-secondary); font-style: normal; padding-top: 0.25rem; }
|
||||||
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; display: flex; gap: 0.5rem; align-items: flex-start; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; min-width: 0; }
|
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; display: flex; gap: 0.5rem; align-items: flex-start; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; min-width: 0; }
|
||||||
.highlight-comment-icon { flex-shrink: 0; margin-top: 0.125rem; }
|
.highlight-comment-icon { flex-shrink: 0; margin-top: 0.125rem; }
|
||||||
@@ -177,7 +210,10 @@
|
|||||||
padding: 0.25rem; /* CompactButton base */
|
padding: 0.25rem; /* CompactButton base */
|
||||||
}
|
}
|
||||||
.highlight-menu-wrapper { position: relative; flex-shrink: 0; display: flex; align-items: center; }
|
.highlight-menu-wrapper { position: relative; flex-shrink: 0; display: flex; align-items: center; }
|
||||||
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 160px; overflow: hidden; }
|
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); bottom: auto; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 160px; overflow: hidden; }
|
||||||
|
/* Open menu upward when there's not enough space below */
|
||||||
|
.highlight-menu-wrapper:last-child .highlight-menu,
|
||||||
|
.highlight-item:last-child .highlight-menu-wrapper .highlight-menu { top: auto; bottom: calc(100% + 4px); }
|
||||||
.highlight-menu-item { width: 100%; background: none; border: none; color: var(--color-text); padding: 0.625rem 0.875rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.625rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
|
.highlight-menu-item { width: 100%; background: none; border: none; color: var(--color-text); padding: 0.625rem 0.875rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.625rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
|
||||||
.highlight-menu-item:hover { background: rgba(99, 102, 241, 0.15); color: var(--color-text); }
|
.highlight-menu-item:hover { background: rgba(99, 102, 241, 0.15); color: var(--color-text); }
|
||||||
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
|
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|||||||
@@ -15,3 +15,23 @@ export const HIGHLIGHT_COLORS = [
|
|||||||
{ name: 'Blue', value: '#3b82f6' }, // blue-500
|
{ name: 'Blue', value: '#3b82f6' }, // blue-500
|
||||||
{ name: 'Purple', value: '#9333ea' } // purple-600
|
{ name: 'Purple', value: '#9333ea' } // purple-600
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Tailwind color palette for link colors - optimized for dark themes
|
||||||
|
export const LINK_COLORS_DARK = [
|
||||||
|
{ name: 'Sky Blue', value: '#38bdf8' }, // sky-400
|
||||||
|
{ name: 'Cyan', value: '#22d3ee' }, // cyan-400
|
||||||
|
{ name: 'Light Blue', value: '#60a5fa' }, // blue-400
|
||||||
|
{ name: 'Indigo Light', value: '#818cf8' }, // indigo-400
|
||||||
|
{ name: 'Blue', value: '#3b82f6' }, // blue-500
|
||||||
|
{ name: 'Purple', value: '#9333ea' } // purple-600
|
||||||
|
]
|
||||||
|
|
||||||
|
// Tailwind color palette for link colors - optimized for light themes
|
||||||
|
export const LINK_COLORS_LIGHT = [
|
||||||
|
{ name: 'Blue', value: '#3b82f6' }, // blue-500
|
||||||
|
{ name: 'Indigo', value: '#6366f1' }, // indigo-500
|
||||||
|
{ name: 'Purple', value: '#9333ea' }, // purple-600
|
||||||
|
{ name: 'Sky Blue', value: '#0ea5e9' }, // sky-500 (darker for light bg)
|
||||||
|
{ name: 'Cyan', value: '#06b6d4' }, // cyan-500 (darker for light bg)
|
||||||
|
{ name: 'Teal', value: '#14b8a6' } // teal-500
|
||||||
|
]
|
||||||
|
|||||||
@@ -39,6 +39,24 @@ export const classifyUrl = (url: string | undefined): UrlClassification => {
|
|||||||
return { type: 'article' }
|
return { type: 'article' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a relay URL to match what applesauce-relay stores internally
|
||||||
|
* Adds trailing slash for URLs without a path
|
||||||
|
*/
|
||||||
|
export function normalizeRelayUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
// If the pathname is empty or just "/", ensure it ends with "/"
|
||||||
|
if (parsed.pathname === '' || parsed.pathname === '/') {
|
||||||
|
return url.endsWith('/') ? url : url + '/'
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, return as-is
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a relay URL is a local relay (localhost or 127.0.0.1)
|
* Checks if a relay URL is a local relay (localhost or 127.0.0.1)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,40 +1,2 @@
|
|||||||
import { NostrEvent } from 'nostr-tools'
|
export { extractProfileDisplayName } from '../../lib/profile'
|
||||||
import { getNpubFallbackDisplay } from './nostrUriResolver'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract display name from a profile event (kind:0) with consistent priority order
|
|
||||||
* Priority: name || display_name || nip05 || npub fallback
|
|
||||||
*
|
|
||||||
* @param profileEvent The profile event (kind:0) to extract name from
|
|
||||||
* @returns Display name string, or empty string if event is invalid
|
|
||||||
*/
|
|
||||||
export function extractProfileDisplayName(profileEvent: NostrEvent | null | undefined): string {
|
|
||||||
if (!profileEvent || profileEvent.kind !== 0) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const profileData = JSON.parse(profileEvent.content || '{}') as {
|
|
||||||
name?: string
|
|
||||||
display_name?: string
|
|
||||||
nip05?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consistent priority: name || display_name || nip05
|
|
||||||
if (profileData.name) return profileData.name
|
|
||||||
if (profileData.display_name) return profileData.display_name
|
|
||||||
if (profileData.nip05) return profileData.nip05
|
|
||||||
|
|
||||||
// Fallback to npub if no name fields
|
|
||||||
return getNpubFallbackDisplay(profileEvent.pubkey)
|
|
||||||
} catch (error) {
|
|
||||||
// If JSON parsing fails, use npub fallback
|
|
||||||
try {
|
|
||||||
return getNpubFallbackDisplay(profileEvent.pubkey)
|
|
||||||
} catch {
|
|
||||||
// If npub encoding also fails, return empty string
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
217
test/markdown/basic-blockquotes.md
Normal file
217
test/markdown/basic-blockquotes.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# Basic Blockquotes Test
|
||||||
|
|
||||||
|
This file tests blockquote syntax using the `>` character.
|
||||||
|
|
||||||
|
## Basic Blockquotes
|
||||||
|
|
||||||
|
Blockquotes are created by placing a `>` character at the start of a line, followed by a space.
|
||||||
|
|
||||||
|
> This is a blockquote.
|
||||||
|
|
||||||
|
> This is another blockquote with multiple sentences. It demonstrates that blockquotes can contain extended text. The entire blockquote should be rendered with appropriate styling to distinguish it from regular paragraphs.
|
||||||
|
|
||||||
|
## Multiple Paragraph Blockquotes
|
||||||
|
|
||||||
|
Blockquotes can span multiple paragraphs by placing `>` at the start of each paragraph.
|
||||||
|
|
||||||
|
> This is the first paragraph in a blockquote.
|
||||||
|
>
|
||||||
|
> This is the second paragraph in the same blockquote.
|
||||||
|
|
||||||
|
> First paragraph.
|
||||||
|
>
|
||||||
|
> Second paragraph.
|
||||||
|
>
|
||||||
|
> Third paragraph.
|
||||||
|
|
||||||
|
## Blockquotes with Formatting
|
||||||
|
|
||||||
|
Blockquotes can contain inline formatting like bold, italic, and code.
|
||||||
|
|
||||||
|
> This blockquote contains **bold text**.
|
||||||
|
|
||||||
|
> This blockquote contains *italic text*.
|
||||||
|
|
||||||
|
> This blockquote contains ***bold and italic text***.
|
||||||
|
|
||||||
|
> This blockquote contains `code text`.
|
||||||
|
|
||||||
|
> This blockquote contains **bold**, *italic*, and `code` all together.
|
||||||
|
|
||||||
|
## Blockquotes with Links
|
||||||
|
|
||||||
|
Blockquotes can contain links.
|
||||||
|
|
||||||
|
> This blockquote contains a [link to example.com](https://example.com).
|
||||||
|
|
||||||
|
> This blockquote contains a [reference link][ref].
|
||||||
|
|
||||||
|
[ref]: https://example.com
|
||||||
|
|
||||||
|
## Nested Blockquotes
|
||||||
|
|
||||||
|
Blockquotes can be nested by using multiple `>` characters.
|
||||||
|
|
||||||
|
> This is the first level of a blockquote.
|
||||||
|
>
|
||||||
|
> > This is a nested blockquote.
|
||||||
|
>
|
||||||
|
> Back to the first level.
|
||||||
|
|
||||||
|
> First level.
|
||||||
|
>
|
||||||
|
> > Second level.
|
||||||
|
>
|
||||||
|
> > > Third level.
|
||||||
|
>
|
||||||
|
> > Back to second level.
|
||||||
|
>
|
||||||
|
> Back to first level.
|
||||||
|
|
||||||
|
## Blockquotes with Lists
|
||||||
|
|
||||||
|
Blockquotes can contain lists.
|
||||||
|
|
||||||
|
> This is a blockquote with a list:
|
||||||
|
>
|
||||||
|
> - First item
|
||||||
|
> - Second item
|
||||||
|
> - Third item
|
||||||
|
|
||||||
|
> This is a blockquote with a numbered list:
|
||||||
|
>
|
||||||
|
> 1. First item
|
||||||
|
> 2. Second item
|
||||||
|
> 3. Third item
|
||||||
|
|
||||||
|
> This is a blockquote with a nested list:
|
||||||
|
>
|
||||||
|
> - First item
|
||||||
|
> - Nested item
|
||||||
|
> - Another nested item
|
||||||
|
> - Second item
|
||||||
|
|
||||||
|
## Blockquotes with Code
|
||||||
|
|
||||||
|
Blockquotes can contain inline code and code blocks.
|
||||||
|
|
||||||
|
> This blockquote contains `inline code`.
|
||||||
|
|
||||||
|
> This blockquote contains a code block:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> Code block here
|
||||||
|
> More code
|
||||||
|
> ```
|
||||||
|
|
||||||
|
> This blockquote contains a code block with language:
|
||||||
|
>
|
||||||
|
> ```javascript
|
||||||
|
> function example() {
|
||||||
|
> return "Hello";
|
||||||
|
> }
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## Blockquotes with Headings
|
||||||
|
|
||||||
|
Blockquotes can contain headings.
|
||||||
|
|
||||||
|
> # Heading Level 1
|
||||||
|
>
|
||||||
|
> ## Heading Level 2
|
||||||
|
>
|
||||||
|
> ### Heading Level 3
|
||||||
|
|
||||||
|
## Blockquotes with Horizontal Rules
|
||||||
|
|
||||||
|
Blockquotes can contain horizontal rules.
|
||||||
|
|
||||||
|
> This is text before the rule.
|
||||||
|
>
|
||||||
|
> ---
|
||||||
|
>
|
||||||
|
> This is text after the rule.
|
||||||
|
|
||||||
|
## Multiple Blockquotes
|
||||||
|
|
||||||
|
Multiple blockquotes can appear consecutively.
|
||||||
|
|
||||||
|
> This is the first blockquote.
|
||||||
|
|
||||||
|
> This is the second blockquote.
|
||||||
|
|
||||||
|
> This is the third blockquote.
|
||||||
|
|
||||||
|
## Blockquotes in Context
|
||||||
|
|
||||||
|
Blockquotes can appear alongside regular paragraphs and other elements.
|
||||||
|
|
||||||
|
This is a regular paragraph before the blockquote.
|
||||||
|
|
||||||
|
> This is a blockquote between paragraphs.
|
||||||
|
|
||||||
|
This is a regular paragraph after the blockquote.
|
||||||
|
|
||||||
|
## Empty Blockquotes
|
||||||
|
|
||||||
|
An empty blockquote can be created, though it may not render visibly.
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
## Blockquotes with Special Characters
|
||||||
|
|
||||||
|
Blockquotes can contain special characters and punctuation.
|
||||||
|
|
||||||
|
> This blockquote has numbers: 123, 456, 789.
|
||||||
|
|
||||||
|
> This blockquote has symbols: !@#$%^&*().
|
||||||
|
|
||||||
|
> This blockquote has quotes: "Hello" and 'World'.
|
||||||
|
|
||||||
|
> This blockquote has parentheses (like this) and brackets [like this].
|
||||||
|
|
||||||
|
## Long Blockquotes
|
||||||
|
|
||||||
|
Blockquotes can contain very long text that wraps across multiple lines.
|
||||||
|
|
||||||
|
> This is a very long blockquote that contains a substantial amount of text. It demonstrates how blockquotes handle extended content that might wrap across multiple visual lines in the rendered output. The blockquote should maintain its styling and indentation even when the text extends beyond a single line.
|
||||||
|
|
||||||
|
## Blockquotes with Mixed Content
|
||||||
|
|
||||||
|
Blockquotes can contain a mix of different content types.
|
||||||
|
|
||||||
|
> This blockquote contains **bold text**, *italic text*, and `code`.
|
||||||
|
>
|
||||||
|
> It also contains a [link](https://example.com).
|
||||||
|
>
|
||||||
|
> - And a list item
|
||||||
|
> - Another list item
|
||||||
|
>
|
||||||
|
> And more regular text.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Blockquote with Only Spaces
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
### Blockquote with Trailing Spaces
|
||||||
|
|
||||||
|
> Blockquote with trailing spaces.
|
||||||
|
|
||||||
|
### Very Short Blockquote
|
||||||
|
|
||||||
|
> A
|
||||||
|
|
||||||
|
### Blockquote with Only Special Characters
|
||||||
|
|
||||||
|
> !@#$%^&*()
|
||||||
|
|
||||||
|
### Blockquote Marker Without Space
|
||||||
|
|
||||||
|
>This might not render correctly in some processors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-blockquotes.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-blockquotes.md)
|
||||||
|
|
||||||
310
test/markdown/basic-code.md
Normal file
310
test/markdown/basic-code.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# Basic Code Test
|
||||||
|
|
||||||
|
This file tests inline code and code block syntax in markdown.
|
||||||
|
|
||||||
|
## Inline Code
|
||||||
|
|
||||||
|
Inline code is created using backticks (`` ` ``) around the text.
|
||||||
|
|
||||||
|
This paragraph contains `inline code`.
|
||||||
|
|
||||||
|
You can use `inline code` anywhere in a sentence.
|
||||||
|
|
||||||
|
`Code` can appear at the start of a sentence.
|
||||||
|
|
||||||
|
A sentence can end with `code`.
|
||||||
|
|
||||||
|
## Code Blocks
|
||||||
|
|
||||||
|
Code blocks are created using triple backticks (``` ``` ```) on lines before and after the code.
|
||||||
|
|
||||||
|
```
|
||||||
|
This is a code block.
|
||||||
|
It can contain multiple lines.
|
||||||
|
Each line is preserved as written.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Blocks with Language
|
||||||
|
|
||||||
|
Code blocks can specify a programming language for syntax highlighting.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function example() {
|
||||||
|
return "Hello, World!";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
def example():
|
||||||
|
return "Hello, World!"
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div>
|
||||||
|
<p>Hello, World!</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.example {
|
||||||
|
color: blue;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple Code Blocks
|
||||||
|
|
||||||
|
Multiple code blocks can appear consecutively.
|
||||||
|
|
||||||
|
```
|
||||||
|
First code block
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Second code block
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Third code block
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Blocks with Formatting
|
||||||
|
|
||||||
|
Code blocks preserve all formatting, including spaces and indentation.
|
||||||
|
|
||||||
|
```
|
||||||
|
This code block
|
||||||
|
has indentation
|
||||||
|
that should be preserved
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
function example() {
|
||||||
|
if (condition) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Blocks with Special Characters
|
||||||
|
|
||||||
|
Code blocks can contain special characters and symbols.
|
||||||
|
|
||||||
|
```
|
||||||
|
!@#$%^&*()
|
||||||
|
[]{}()
|
||||||
|
<>
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
function test() {
|
||||||
|
console.log("Hello, World!");
|
||||||
|
return 123;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inline Code with Special Characters
|
||||||
|
|
||||||
|
Inline code can contain special characters.
|
||||||
|
|
||||||
|
This has `code with !@#$%` in it.
|
||||||
|
|
||||||
|
This has `code with ()[]{}` in it.
|
||||||
|
|
||||||
|
This has `code with <>&` in it.
|
||||||
|
|
||||||
|
## Escaping Backticks in Inline Code
|
||||||
|
|
||||||
|
To include a backtick in inline code, use double backticks.
|
||||||
|
|
||||||
|
This contains `` `backtick` `` in the code.
|
||||||
|
|
||||||
|
This contains `` `code with backticks` `` in it.
|
||||||
|
|
||||||
|
## Code Blocks with Empty Lines
|
||||||
|
|
||||||
|
Code blocks can contain empty lines.
|
||||||
|
|
||||||
|
```
|
||||||
|
First line
|
||||||
|
|
||||||
|
Third line
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Line one
|
||||||
|
|
||||||
|
Line three
|
||||||
|
|
||||||
|
Line five
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Blocks with Only Whitespace
|
||||||
|
|
||||||
|
Code blocks can contain only whitespace.
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inline Code in Different Contexts
|
||||||
|
|
||||||
|
Inline code can appear in various contexts.
|
||||||
|
|
||||||
|
### In Paragraphs
|
||||||
|
|
||||||
|
This paragraph has `inline code` in it.
|
||||||
|
|
||||||
|
### In Lists
|
||||||
|
|
||||||
|
- Item with `inline code`
|
||||||
|
- Another item with `code`
|
||||||
|
|
||||||
|
1. Ordered item with `inline code`
|
||||||
|
2. Another ordered item with `code`
|
||||||
|
|
||||||
|
### In Blockquotes
|
||||||
|
|
||||||
|
> This blockquote contains `inline code`.
|
||||||
|
|
||||||
|
### In Headings
|
||||||
|
|
||||||
|
### Heading with `Code`
|
||||||
|
|
||||||
|
## Code Blocks in Different Contexts
|
||||||
|
|
||||||
|
### Code Blocks After Paragraphs
|
||||||
|
|
||||||
|
This is a paragraph.
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block after paragraph
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Blocks Before Paragraphs
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block before paragraph
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a paragraph.
|
||||||
|
|
||||||
|
### Code Blocks Between Paragraphs
|
||||||
|
|
||||||
|
This is the first paragraph.
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block in the middle
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the second paragraph.
|
||||||
|
|
||||||
|
### Code Blocks in Lists
|
||||||
|
|
||||||
|
- List item before code block
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block in list
|
||||||
|
```
|
||||||
|
|
||||||
|
- List item after code block
|
||||||
|
|
||||||
|
### Code Blocks in Blockquotes
|
||||||
|
|
||||||
|
> This is a blockquote with a code block:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> Code block in blockquote
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## Long Code Blocks
|
||||||
|
|
||||||
|
Code blocks can contain very long lines of code.
|
||||||
|
|
||||||
|
```
|
||||||
|
This is a very long line of code that extends far beyond the normal width and should demonstrate how code blocks handle extended content that might require horizontal scrolling or wrapping depending on the rendering implementation.
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
function veryLongFunctionNameThatExtendsBeyondNormalWidth(parameterOne, parameterTwo, parameterThree, parameterFour) {
|
||||||
|
return parameterOne + parameterTwo + parameterThree + parameterFour;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Blocks with Many Lines
|
||||||
|
|
||||||
|
Code blocks can contain many lines of code.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function example() {
|
||||||
|
let x = 1;
|
||||||
|
let y = 2;
|
||||||
|
let z = 3;
|
||||||
|
let a = 4;
|
||||||
|
let b = 5;
|
||||||
|
let c = 6;
|
||||||
|
let d = 7;
|
||||||
|
let e = 8;
|
||||||
|
let f = 9;
|
||||||
|
let g = 10;
|
||||||
|
return x + y + z + a + b + c + d + e + f + g;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Inline Code with Only Spaces
|
||||||
|
|
||||||
|
` `
|
||||||
|
|
||||||
|
### Inline Code with Only Special Characters
|
||||||
|
|
||||||
|
`!@#$%^&*()`
|
||||||
|
|
||||||
|
### Very Short Inline Code
|
||||||
|
|
||||||
|
`` `a` ``
|
||||||
|
|
||||||
|
### Inline Code at Word Boundaries
|
||||||
|
|
||||||
|
`code`word
|
||||||
|
|
||||||
|
word`code`
|
||||||
|
|
||||||
|
### Code Block with Only One Line
|
||||||
|
|
||||||
|
```
|
||||||
|
Single line code block
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Block with Trailing Spaces
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block with trailing spaces
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unclosed Code Block
|
||||||
|
|
||||||
|
```
|
||||||
|
This code block is not closed properly
|
||||||
|
|
||||||
|
### Code Block with Language but No Code
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline Code with Backticks
|
||||||
|
|
||||||
|
`` `code` ``
|
||||||
|
|
||||||
|
`` ``code`` ``
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-code.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-code.md)
|
||||||
|
|
||||||
194
test/markdown/basic-emphasis.md
Normal file
194
test/markdown/basic-emphasis.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# Basic Emphasis Test
|
||||||
|
|
||||||
|
This file tests bold and italic text formatting using asterisks and underscores.
|
||||||
|
|
||||||
|
## Bold Text
|
||||||
|
|
||||||
|
Bold text is created using two asterisks or two underscores before and after the text.
|
||||||
|
|
||||||
|
I just love **bold text**.
|
||||||
|
|
||||||
|
I also love __bold text with underscores__.
|
||||||
|
|
||||||
|
**Bold text** can appear at the start of a sentence.
|
||||||
|
|
||||||
|
A sentence can end with **bold text**.
|
||||||
|
|
||||||
|
A sentence can have **bold text** in the middle.
|
||||||
|
|
||||||
|
## Italic Text
|
||||||
|
|
||||||
|
Italic text is created using one asterisk or one underscore before and after the text.
|
||||||
|
|
||||||
|
Italicized text is the *cat's meow*.
|
||||||
|
|
||||||
|
Italicized text is also the _cat's meow_.
|
||||||
|
|
||||||
|
*Italic text* can appear at the start of a sentence.
|
||||||
|
|
||||||
|
A sentence can end with *italic text*.
|
||||||
|
|
||||||
|
A sentence can have *italic text* in the middle.
|
||||||
|
|
||||||
|
## Bold and Italic Together
|
||||||
|
|
||||||
|
To emphasize text with both bold and italics, use three asterisks or three underscores.
|
||||||
|
|
||||||
|
This text is ***bold and italic***.
|
||||||
|
|
||||||
|
This text is also ___bold and italic___.
|
||||||
|
|
||||||
|
***Bold and italic*** can appear at the start of a sentence.
|
||||||
|
|
||||||
|
A sentence can end with ***bold and italic***.
|
||||||
|
|
||||||
|
A sentence can have ***bold and italic*** in the middle.
|
||||||
|
|
||||||
|
## Mid-Word Emphasis
|
||||||
|
|
||||||
|
You can emphasize the middle of a word for emphasis.
|
||||||
|
|
||||||
|
Love**is**bold
|
||||||
|
|
||||||
|
Love*is*italic
|
||||||
|
|
||||||
|
Love***is***bolditalic
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Use Asterisks for Mid-Word Emphasis
|
||||||
|
|
||||||
|
For compatibility, use asterisks when emphasizing the middle of a word.
|
||||||
|
|
||||||
|
Love**is**bold (correct)
|
||||||
|
|
||||||
|
Love__is__bold (may not work in all processors)
|
||||||
|
|
||||||
|
A*cat*meow (correct)
|
||||||
|
|
||||||
|
A_cat_meow (may not work in all processors)
|
||||||
|
|
||||||
|
### Spacing Around Emphasis
|
||||||
|
|
||||||
|
Emphasis markers should be directly adjacent to the text being emphasized.
|
||||||
|
|
||||||
|
This is **correct** spacing.
|
||||||
|
|
||||||
|
This is ** incorrect ** spacing.
|
||||||
|
|
||||||
|
This is *correct* spacing.
|
||||||
|
|
||||||
|
This is * incorrect * spacing.
|
||||||
|
|
||||||
|
## Multiple Emphasis in One Paragraph
|
||||||
|
|
||||||
|
A single paragraph can contain multiple instances of bold, italic, and combined emphasis.
|
||||||
|
|
||||||
|
This paragraph has **bold text**, *italic text*, and ***bold italic text*** all together. You can use **multiple bold** sections and *multiple italic* sections in the same paragraph.
|
||||||
|
|
||||||
|
## Emphasis with Punctuation
|
||||||
|
|
||||||
|
Emphasis works correctly with adjacent punctuation marks.
|
||||||
|
|
||||||
|
**Bold text**, with a comma.
|
||||||
|
|
||||||
|
**Bold text.** With a period.
|
||||||
|
|
||||||
|
**Bold text!** With an exclamation.
|
||||||
|
|
||||||
|
**Bold text?** With a question mark.
|
||||||
|
|
||||||
|
*Italic text*, with a comma.
|
||||||
|
|
||||||
|
*Italic text.* With a period.
|
||||||
|
|
||||||
|
*Italic text!* With an exclamation.
|
||||||
|
|
||||||
|
*Italic text?* With a question mark.
|
||||||
|
|
||||||
|
## Emphasis at Word Boundaries
|
||||||
|
|
||||||
|
Emphasis can appear at the start or end of words.
|
||||||
|
|
||||||
|
**Start** of a word.
|
||||||
|
|
||||||
|
End of a **word**.
|
||||||
|
|
||||||
|
*Start* of a word.
|
||||||
|
|
||||||
|
End of a *word*.
|
||||||
|
|
||||||
|
## Emphasis with Links
|
||||||
|
|
||||||
|
Emphasis can be combined with links.
|
||||||
|
|
||||||
|
This is a [**bold link**](https://example.com).
|
||||||
|
|
||||||
|
This is a [*italic link*](https://example.com).
|
||||||
|
|
||||||
|
This is a [***bold italic link***](https://example.com).
|
||||||
|
|
||||||
|
## Emphasis with Code
|
||||||
|
|
||||||
|
Emphasis cannot be used inside code blocks, but can appear alongside inline code.
|
||||||
|
|
||||||
|
This has `code` and **bold** together.
|
||||||
|
|
||||||
|
This has `code` and *italic* together.
|
||||||
|
|
||||||
|
## Nested Emphasis
|
||||||
|
|
||||||
|
You cannot nest emphasis of the same type, but you can combine different types.
|
||||||
|
|
||||||
|
***Bold and italic*** is valid.
|
||||||
|
|
||||||
|
**Bold with *italic inside* bold** is valid.
|
||||||
|
|
||||||
|
*Italic with **bold inside** italic* is valid.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Emphasis with Only Spaces
|
||||||
|
|
||||||
|
** **
|
||||||
|
|
||||||
|
* *
|
||||||
|
|
||||||
|
### Emphasis with Special Characters
|
||||||
|
|
||||||
|
**Bold with !@#$%**
|
||||||
|
|
||||||
|
*Italic with !@#$%*
|
||||||
|
|
||||||
|
### Very Short Emphasis
|
||||||
|
|
||||||
|
****
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
**
|
||||||
|
|
||||||
|
*
|
||||||
|
|
||||||
|
### Emphasis Markers Without Closing
|
||||||
|
|
||||||
|
**Bold text without closing
|
||||||
|
|
||||||
|
*Italic text without closing
|
||||||
|
|
||||||
|
### Emphasis with Numbers
|
||||||
|
|
||||||
|
**123**
|
||||||
|
|
||||||
|
*456*
|
||||||
|
|
||||||
|
### Emphasis with Only Punctuation
|
||||||
|
|
||||||
|
**!!!**
|
||||||
|
|
||||||
|
*???*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-emphasis.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-emphasis.md)
|
||||||
|
|
||||||
219
test/markdown/basic-escaping.md
Normal file
219
test/markdown/basic-escaping.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Basic Escaping Test
|
||||||
|
|
||||||
|
This file tests character escaping in markdown using backslashes to display literal characters that would otherwise have special meaning.
|
||||||
|
|
||||||
|
## Escaping Special Characters
|
||||||
|
|
||||||
|
You can escape special markdown characters by placing a backslash (`\`) before them.
|
||||||
|
|
||||||
|
### Backslash
|
||||||
|
|
||||||
|
To display a literal backslash, escape it: \\
|
||||||
|
|
||||||
|
### Backtick
|
||||||
|
|
||||||
|
To display a literal backtick, escape it: \`
|
||||||
|
|
||||||
|
### Asterisk
|
||||||
|
|
||||||
|
To display a literal asterisk, escape it: \*
|
||||||
|
|
||||||
|
### Underscore
|
||||||
|
|
||||||
|
To display a literal underscore, escape it: \_
|
||||||
|
|
||||||
|
### Curly Braces
|
||||||
|
|
||||||
|
To display literal curly braces, escape them: \{ \}
|
||||||
|
|
||||||
|
### Square Brackets
|
||||||
|
|
||||||
|
To display literal square brackets, escape them: \[ \]
|
||||||
|
|
||||||
|
### Angle Brackets
|
||||||
|
|
||||||
|
To display literal angle brackets, escape them: \< \>
|
||||||
|
|
||||||
|
### Parentheses
|
||||||
|
|
||||||
|
To display literal parentheses, escape them: \( \)
|
||||||
|
|
||||||
|
### Pound Sign
|
||||||
|
|
||||||
|
To display a literal pound sign (hash), escape it: \#
|
||||||
|
|
||||||
|
### Plus Sign
|
||||||
|
|
||||||
|
To display a literal plus sign, escape it: \+
|
||||||
|
|
||||||
|
### Minus Sign
|
||||||
|
|
||||||
|
To display a literal minus sign (hyphen), escape it: \-
|
||||||
|
|
||||||
|
### Dot
|
||||||
|
|
||||||
|
To display a literal dot (period), escape it: \.
|
||||||
|
|
||||||
|
### Exclamation Mark
|
||||||
|
|
||||||
|
To display a literal exclamation mark, escape it: \!
|
||||||
|
|
||||||
|
### Pipe
|
||||||
|
|
||||||
|
To display a literal pipe character, escape it: \|
|
||||||
|
|
||||||
|
## Escaping in Different Contexts
|
||||||
|
|
||||||
|
### Escaping in Paragraphs
|
||||||
|
|
||||||
|
This paragraph contains escaped characters: \*asterisk\*, \_underscore\_, \`backtick\`.
|
||||||
|
|
||||||
|
### Escaping in Headings
|
||||||
|
|
||||||
|
#### Heading with \*Escaped\* Characters
|
||||||
|
|
||||||
|
#### Heading with \_Escaped\_ Characters
|
||||||
|
|
||||||
|
### Escaping in Lists
|
||||||
|
|
||||||
|
- Item with \*escaped asterisk\*
|
||||||
|
- Item with \_escaped underscore\_
|
||||||
|
- Item with \`escaped backtick\`
|
||||||
|
|
||||||
|
1. Ordered item with \*escaped\*
|
||||||
|
2. Another item with \_escaped\_
|
||||||
|
|
||||||
|
### Escaping in Blockquotes
|
||||||
|
|
||||||
|
> This blockquote contains \*escaped\* characters.
|
||||||
|
|
||||||
|
> This blockquote has \_escaped\_ underscores.
|
||||||
|
|
||||||
|
### Escaping in Links
|
||||||
|
|
||||||
|
You cannot escape characters inside link syntax, but you can escape them in the link text context.
|
||||||
|
|
||||||
|
This is a [link with \*escaped\* text](https://example.com).
|
||||||
|
|
||||||
|
## Multiple Escaped Characters
|
||||||
|
|
||||||
|
You can escape multiple characters in sequence.
|
||||||
|
|
||||||
|
\*\*This would be bold if not escaped\*\*
|
||||||
|
|
||||||
|
\*\*\*This would be bold and italic if not escaped\*\*\*
|
||||||
|
|
||||||
|
\`\`This would be code if not escaped\`\`
|
||||||
|
|
||||||
|
## Escaping vs. Not Escaping
|
||||||
|
|
||||||
|
### Without Escaping
|
||||||
|
|
||||||
|
This text has **bold** and *italic* formatting.
|
||||||
|
|
||||||
|
### With Escaping
|
||||||
|
|
||||||
|
This text has \*\*escaped bold\*\* and \*escaped italic\* markers.
|
||||||
|
|
||||||
|
## Escaping Special Characters in Code
|
||||||
|
|
||||||
|
Inside code blocks and inline code, characters are already literal and don't need escaping.
|
||||||
|
|
||||||
|
```
|
||||||
|
This code block contains *asterisks* and _underscores_ without escaping.
|
||||||
|
```
|
||||||
|
|
||||||
|
This paragraph contains `inline code with *asterisks*` that don't need escaping.
|
||||||
|
|
||||||
|
## Escaping at Word Boundaries
|
||||||
|
|
||||||
|
Escaped characters can appear at the start or end of words.
|
||||||
|
|
||||||
|
\*Start of word
|
||||||
|
|
||||||
|
End of word\*
|
||||||
|
|
||||||
|
\_Start of word
|
||||||
|
|
||||||
|
End of word\_
|
||||||
|
|
||||||
|
## Escaping with Punctuation
|
||||||
|
|
||||||
|
Escaped characters work correctly with adjacent punctuation.
|
||||||
|
|
||||||
|
\*Asterisk\*, with comma.
|
||||||
|
|
||||||
|
\*Asterisk\*. With period.
|
||||||
|
|
||||||
|
\*Asterisk\*! With exclamation.
|
||||||
|
|
||||||
|
\_Underscore\_, with comma.
|
||||||
|
|
||||||
|
\_Underscore\_. With period.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Escaping Non-Special Characters
|
||||||
|
|
||||||
|
Escaping characters that don't have special meaning in markdown typically results in a literal backslash followed by the character.
|
||||||
|
|
||||||
|
\a
|
||||||
|
|
||||||
|
\b
|
||||||
|
|
||||||
|
\c
|
||||||
|
|
||||||
|
### Multiple Backslashes
|
||||||
|
|
||||||
|
\\\\
|
||||||
|
|
||||||
|
\\\\\\
|
||||||
|
|
||||||
|
### Escaping Spaces
|
||||||
|
|
||||||
|
Escaping a space typically doesn't have a special effect: \
|
||||||
|
|
||||||
|
### Escaping Newlines
|
||||||
|
|
||||||
|
Escaping a newline (backslash at end of line) may create a line break in some processors, but this is not part of basic markdown syntax.
|
||||||
|
|
||||||
|
### Escaping in Different Positions
|
||||||
|
|
||||||
|
Start: \*text
|
||||||
|
|
||||||
|
Middle: text\*text
|
||||||
|
|
||||||
|
End: text\*
|
||||||
|
|
||||||
|
### Escaping Special Character Sequences
|
||||||
|
|
||||||
|
\*\*\*
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\-\-\-
|
||||||
|
|
||||||
|
## Real-World Examples
|
||||||
|
|
||||||
|
### Escaping in Documentation
|
||||||
|
|
||||||
|
When writing documentation about markdown, you often need to escape characters to show the syntax.
|
||||||
|
|
||||||
|
To create bold text, use \*\*two asterisks\*\*.
|
||||||
|
|
||||||
|
To create italic text, use \*one asterisk\*.
|
||||||
|
|
||||||
|
To create inline code, use \`backticks\`.
|
||||||
|
|
||||||
|
### Escaping in Examples
|
||||||
|
|
||||||
|
Here's how to escape a backtick: \`
|
||||||
|
|
||||||
|
Here's how to escape an asterisk: \*
|
||||||
|
|
||||||
|
Here's how to escape an underscore: \_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-escaping.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-escaping.md)
|
||||||
|
|
||||||
140
test/markdown/basic-headings.md
Normal file
140
test/markdown/basic-headings.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Basic Headings Test
|
||||||
|
|
||||||
|
This file tests markdown heading syntax, including all heading levels and alternate syntax forms.
|
||||||
|
|
||||||
|
## Heading Levels
|
||||||
|
|
||||||
|
Headings are created using number signs (`#`) followed by a space and the heading text. The number of `#` symbols determines the heading level.
|
||||||
|
|
||||||
|
# Heading Level 1
|
||||||
|
|
||||||
|
## Heading Level 2
|
||||||
|
|
||||||
|
### Heading Level 3
|
||||||
|
|
||||||
|
#### Heading Level 4
|
||||||
|
|
||||||
|
##### Heading Level 5
|
||||||
|
|
||||||
|
###### Heading Level 6
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
Always include a space between the number signs and the heading text for compatibility across markdown processors.
|
||||||
|
|
||||||
|
# Correct: Space after #
|
||||||
|
|
||||||
|
#Incorrect: No space after #
|
||||||
|
|
||||||
|
## Blank Lines
|
||||||
|
|
||||||
|
For best compatibility, include blank lines before and after headings.
|
||||||
|
|
||||||
|
This paragraph is before the heading.
|
||||||
|
|
||||||
|
# Heading with blank lines
|
||||||
|
|
||||||
|
This paragraph is after the heading.
|
||||||
|
|
||||||
|
Without blank lines, this might not render correctly.
|
||||||
|
# Heading without blank lines
|
||||||
|
This text might be treated as part of the heading.
|
||||||
|
|
||||||
|
## Alternate Syntax (Setext)
|
||||||
|
|
||||||
|
Heading level 1 can also be created using equals signs (`=`) on the line below the text.
|
||||||
|
|
||||||
|
Heading Level 1
|
||||||
|
===============
|
||||||
|
|
||||||
|
Heading level 2 can be created using hyphens (`-`) on the line below the text.
|
||||||
|
|
||||||
|
Heading Level 2
|
||||||
|
---------------
|
||||||
|
|
||||||
|
## Headings with Formatting
|
||||||
|
|
||||||
|
Headings can contain inline formatting like bold and italic text.
|
||||||
|
|
||||||
|
### Heading with **Bold** Text
|
||||||
|
|
||||||
|
### Heading with *Italic* Text
|
||||||
|
|
||||||
|
### Heading with ***Bold and Italic*** Text
|
||||||
|
|
||||||
|
### Heading with `Code` Text
|
||||||
|
|
||||||
|
## Headings with Links
|
||||||
|
|
||||||
|
Headings can contain links.
|
||||||
|
|
||||||
|
### Heading with [Link](https://example.com)
|
||||||
|
|
||||||
|
### Heading with [Reference Link][ref]
|
||||||
|
|
||||||
|
[ref]: https://example.com
|
||||||
|
|
||||||
|
## Long Headings
|
||||||
|
|
||||||
|
This tests how headings handle very long text that might wrap across multiple lines on smaller screens or in narrow containers.
|
||||||
|
|
||||||
|
# This is a very long heading that contains many words and should demonstrate how the markdown processor handles headings that extend beyond a single line of text
|
||||||
|
|
||||||
|
## Special Characters in Headings
|
||||||
|
|
||||||
|
Headings can contain various special characters and punctuation.
|
||||||
|
|
||||||
|
### Heading with Numbers: 123
|
||||||
|
|
||||||
|
### Heading with Symbols: !@#$%^&*()
|
||||||
|
|
||||||
|
### Heading with Quotes: "Hello World"
|
||||||
|
|
||||||
|
### Heading with Parentheses (Like This)
|
||||||
|
|
||||||
|
### Heading with Brackets [Like This]
|
||||||
|
|
||||||
|
### Heading with Braces {Like This}
|
||||||
|
|
||||||
|
## Multiple Headings
|
||||||
|
|
||||||
|
Multiple headings of the same or different levels can appear consecutively.
|
||||||
|
|
||||||
|
# First H1
|
||||||
|
|
||||||
|
# Second H1
|
||||||
|
|
||||||
|
## First H2
|
||||||
|
|
||||||
|
## Second H2
|
||||||
|
|
||||||
|
### First H3
|
||||||
|
|
||||||
|
### Second H3
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Heading with Only Spaces
|
||||||
|
|
||||||
|
#
|
||||||
|
|
||||||
|
### Heading with Trailing Spaces
|
||||||
|
|
||||||
|
# Heading with trailing spaces
|
||||||
|
|
||||||
|
### Heading Starting with Number Sign
|
||||||
|
|
||||||
|
# #Heading that starts with a number sign
|
||||||
|
|
||||||
|
### Very Short Heading
|
||||||
|
|
||||||
|
# A
|
||||||
|
|
||||||
|
### Heading with Only Special Characters
|
||||||
|
|
||||||
|
# !@#$%^&*()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-headings.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-headings.md)
|
||||||
|
|
||||||
194
test/markdown/basic-horizontal-rules.md
Normal file
194
test/markdown/basic-horizontal-rules.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# Basic Horizontal Rules Test
|
||||||
|
|
||||||
|
This file tests horizontal rule syntax using hyphens, asterisks, and underscores.
|
||||||
|
|
||||||
|
## Basic Horizontal Rules
|
||||||
|
|
||||||
|
Horizontal rules are created using three or more hyphens, asterisks, or underscores on their own line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
## Minimum Characters
|
||||||
|
|
||||||
|
At least three characters are required, but more can be used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Different Characters
|
||||||
|
|
||||||
|
Horizontal rules can be created with hyphens, asterisks, or underscores.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
## Horizontal Rules in Context
|
||||||
|
|
||||||
|
Horizontal rules can appear between paragraphs and other elements.
|
||||||
|
|
||||||
|
This is a paragraph before the horizontal rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This is a paragraph after the horizontal rule.
|
||||||
|
|
||||||
|
## Multiple Horizontal Rules
|
||||||
|
|
||||||
|
Multiple horizontal rules can appear consecutively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Horizontal Rules with Formatting
|
||||||
|
|
||||||
|
Horizontal rules are standalone elements and cannot contain formatting, but they can appear alongside formatted content.
|
||||||
|
|
||||||
|
This paragraph has **bold text**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This paragraph has *italic text*.
|
||||||
|
|
||||||
|
## Horizontal Rules with Lists
|
||||||
|
|
||||||
|
Horizontal rules can appear before and after lists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- First item
|
||||||
|
- Second item
|
||||||
|
- Third item
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Horizontal Rules with Blockquotes
|
||||||
|
|
||||||
|
Horizontal rules can appear before and after blockquotes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> This is a blockquote.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Horizontal Rules with Code Blocks
|
||||||
|
|
||||||
|
Horizontal rules can appear before and after code blocks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block here
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Horizontal Rules with Headings
|
||||||
|
|
||||||
|
Horizontal rules can appear before and after headings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Heading Level 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Heading Level 2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing Around Horizontal Rules
|
||||||
|
|
||||||
|
Horizontal rules should be on their own line with blank lines before and after for best compatibility.
|
||||||
|
|
||||||
|
Paragraph before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Paragraph after.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Horizontal Rule with Spaces
|
||||||
|
|
||||||
|
- - -
|
||||||
|
|
||||||
|
* * *
|
||||||
|
|
||||||
|
_ _ _
|
||||||
|
|
||||||
|
### Horizontal Rule with Only Two Characters
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
**
|
||||||
|
|
||||||
|
__
|
||||||
|
|
||||||
|
### Horizontal Rule with Mixed Characters
|
||||||
|
|
||||||
|
-*_
|
||||||
|
|
||||||
|
*-_
|
||||||
|
|
||||||
|
_*-
|
||||||
|
|
||||||
|
### Horizontal Rule with Trailing Spaces
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
### Horizontal Rule with Leading Spaces
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
### Very Long Horizontal Rules
|
||||||
|
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
***********************************
|
||||||
|
|
||||||
|
___________________________________
|
||||||
|
|
||||||
|
### Horizontal Rule Between Other Elements
|
||||||
|
|
||||||
|
# Heading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Another Heading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Third Heading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Paragraph text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-horizontal-rules.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-horizontal-rules.md)
|
||||||
|
|
||||||
95
test/markdown/basic-index.md
Normal file
95
test/markdown/basic-index.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Basic Markdown Syntax Test Index
|
||||||
|
|
||||||
|
This directory contains test files for basic markdown syntax features. Each file focuses on a specific aspect of the [Markdown Guide's Basic Syntax](https://www.markdownguide.org/basic-syntax/).
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
### [basic-headings.md](./basic-headings.md)
|
||||||
|
Tests heading syntax including:
|
||||||
|
- All heading levels (H1 through H6)
|
||||||
|
- Setext-style headings (alternate syntax)
|
||||||
|
- Headings with formatting and links
|
||||||
|
- Best practices for spacing and blank lines
|
||||||
|
|
||||||
|
### [basic-paragraphs-line-breaks.md](./basic-paragraphs-line-breaks.md)
|
||||||
|
Tests paragraph separation and line break syntax:
|
||||||
|
- Paragraph creation with blank lines
|
||||||
|
- Line breaks using trailing spaces
|
||||||
|
- HTML line breaks using `<br>` tag
|
||||||
|
- Best practices for paragraph formatting
|
||||||
|
|
||||||
|
### [basic-emphasis.md](./basic-emphasis.md)
|
||||||
|
Tests bold and italic text formatting:
|
||||||
|
- Bold text with `**` and `__`
|
||||||
|
- Italic text with `*` and `_`
|
||||||
|
- Combined bold and italic
|
||||||
|
- Mid-word emphasis best practices
|
||||||
|
|
||||||
|
### [basic-blockquotes.md](./basic-blockquotes.md)
|
||||||
|
Tests blockquote syntax:
|
||||||
|
- Basic blockquotes with `>`
|
||||||
|
- Multiple paragraph blockquotes
|
||||||
|
- Nested blockquotes
|
||||||
|
- Blockquotes containing lists, code, and formatting
|
||||||
|
|
||||||
|
### [basic-lists.md](./basic-lists.md)
|
||||||
|
Tests ordered and unordered list syntax:
|
||||||
|
- Unordered lists with `-`, `*`, and `+`
|
||||||
|
- Ordered lists with numbers
|
||||||
|
- Nested lists
|
||||||
|
- Lists with formatting, links, and code
|
||||||
|
|
||||||
|
### [basic-code.md](./basic-code.md)
|
||||||
|
Tests inline code and code block syntax:
|
||||||
|
- Inline code with backticks
|
||||||
|
- Code blocks with triple backticks
|
||||||
|
- Code blocks with language specification
|
||||||
|
- Escaping backticks in inline code
|
||||||
|
|
||||||
|
### [basic-horizontal-rules.md](./basic-horizontal-rules.md)
|
||||||
|
Tests horizontal rule syntax:
|
||||||
|
- Horizontal rules with `---`, `***`, and `___`
|
||||||
|
- Minimum character requirements
|
||||||
|
- Horizontal rules in various contexts
|
||||||
|
|
||||||
|
### [basic-links-and-images.md](./basic-links-and-images.md)
|
||||||
|
Tests link and image syntax:
|
||||||
|
- Inline links and reference links
|
||||||
|
- Links with titles
|
||||||
|
- Images with alt text and titles
|
||||||
|
- Linking images
|
||||||
|
- URL encoding best practices
|
||||||
|
|
||||||
|
### [basic-escaping.md](./basic-escaping.md)
|
||||||
|
Tests character escaping:
|
||||||
|
- Escaping special markdown characters with backslashes
|
||||||
|
- Escaping in different contexts
|
||||||
|
- All escapable characters per the Markdown Guide
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
These test files can be:
|
||||||
|
- Viewed in the app's markdown reader
|
||||||
|
- Published to Nostr relays using `./scripts/publish-markdown.sh`
|
||||||
|
|
||||||
|
### Publishing a Test File
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Publish a specific file
|
||||||
|
./scripts/publish-markdown.sh basic-headings.md [wss://relay.example.com]
|
||||||
|
|
||||||
|
# Interactive mode (choose from all files)
|
||||||
|
./scripts/publish-markdown.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- [tables.md](./tables.md) - Tests markdown table syntax (GFM feature, not basic syntax)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- These files test only **basic markdown syntax** as defined in the original Markdown specification
|
||||||
|
- Extended syntax features (like tables, footnotes, task lists) are not included here
|
||||||
|
- Each file starts with an H1 heading for title extraction by the publish script
|
||||||
|
- Files are kept under 420 lines per project conventions
|
||||||
|
|
||||||
217
test/markdown/basic-links-and-images.md
Normal file
217
test/markdown/basic-links-and-images.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# Basic Links and Images Test
|
||||||
|
|
||||||
|
This file tests link and image syntax in markdown, including inline links, reference links, and images.
|
||||||
|
|
||||||
|
## Inline Links
|
||||||
|
|
||||||
|
Inline links are created using square brackets for the link text followed by parentheses containing the URL.
|
||||||
|
|
||||||
|
This is an [inline link](https://example.com).
|
||||||
|
|
||||||
|
You can have [multiple links](https://example.com) in the [same paragraph](https://example.org).
|
||||||
|
|
||||||
|
## Links with Titles
|
||||||
|
|
||||||
|
Links can include an optional title that appears as a tooltip when hovering.
|
||||||
|
|
||||||
|
This is a [link with title](https://example.com "Example Website").
|
||||||
|
|
||||||
|
This is another [link with title](https://example.org 'Another Example').
|
||||||
|
|
||||||
|
This link uses (parentheses) for the title: [link](https://example.com (Title in Parentheses)).
|
||||||
|
|
||||||
|
## Reference Links
|
||||||
|
|
||||||
|
Reference links use a two-part syntax: the link text in square brackets, and the URL definition elsewhere.
|
||||||
|
|
||||||
|
This is a [reference link][example].
|
||||||
|
|
||||||
|
This is another [reference link][another].
|
||||||
|
|
||||||
|
[example]: https://example.com
|
||||||
|
[another]: https://example.org
|
||||||
|
|
||||||
|
## Reference Links with Titles
|
||||||
|
|
||||||
|
Reference links can also include titles.
|
||||||
|
|
||||||
|
This is a [reference link with title][titled].
|
||||||
|
|
||||||
|
[titled]: https://example.com "Link Title"
|
||||||
|
|
||||||
|
This is another [reference link with title][titled2].
|
||||||
|
|
||||||
|
[titled2]: https://example.org 'Another Title'
|
||||||
|
|
||||||
|
## Implicit Reference Links
|
||||||
|
|
||||||
|
You can use the link text itself as the reference identifier.
|
||||||
|
|
||||||
|
This is an [implicit reference link][implicit reference link].
|
||||||
|
|
||||||
|
[implicit reference link]: https://example.com
|
||||||
|
|
||||||
|
## Links in Different Contexts
|
||||||
|
|
||||||
|
### Links in Paragraphs
|
||||||
|
|
||||||
|
This paragraph contains a [link](https://example.com) in the middle of the text.
|
||||||
|
|
||||||
|
### Links in Lists
|
||||||
|
|
||||||
|
- Item with [link](https://example.com)
|
||||||
|
- Another item with [link](https://example.org)
|
||||||
|
|
||||||
|
1. Ordered item with [link](https://example.com)
|
||||||
|
2. Another ordered item with [link](https://example.org)
|
||||||
|
|
||||||
|
### Links in Blockquotes
|
||||||
|
|
||||||
|
> This blockquote contains a [link](https://example.com).
|
||||||
|
|
||||||
|
### Links in Headings
|
||||||
|
|
||||||
|
### Heading with [Link](https://example.com)
|
||||||
|
|
||||||
|
## Links with Formatting
|
||||||
|
|
||||||
|
Links can contain formatting like bold and italic.
|
||||||
|
|
||||||
|
This is a [**bold link**](https://example.com).
|
||||||
|
|
||||||
|
This is a [*italic link*](https://example.com).
|
||||||
|
|
||||||
|
This is a [***bold italic link***](https://example.com).
|
||||||
|
|
||||||
|
## Images
|
||||||
|
|
||||||
|
Images are created using an exclamation mark followed by square brackets for alt text and parentheses for the image URL.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Images with Titles
|
||||||
|
|
||||||
|
Images can include optional titles.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Reference Style Images
|
||||||
|
|
||||||
|
Images can use reference-style syntax.
|
||||||
|
|
||||||
|
![Reference image][img1]
|
||||||
|
|
||||||
|
![Another reference image][img2]
|
||||||
|
|
||||||
|
[img1]: https://example.com/image.jpg
|
||||||
|
[img2]: https://example.com/photo.png "Photo Title"
|
||||||
|
|
||||||
|
## Linking Images
|
||||||
|
|
||||||
|
To create a link that wraps an image, enclose the image syntax in square brackets followed by the link URL in parentheses.
|
||||||
|
|
||||||
|
[](https://example.com)
|
||||||
|
|
||||||
|
[](https://example.org "Link Title")
|
||||||
|
|
||||||
|
## Images in Different Contexts
|
||||||
|
|
||||||
|
### Images in Paragraphs
|
||||||
|
|
||||||
|
This paragraph contains an image: 
|
||||||
|
|
||||||
|
### Images in Lists
|
||||||
|
|
||||||
|
- Item with image: 
|
||||||
|
- Another item with image: 
|
||||||
|
|
||||||
|
### Images in Blockquotes
|
||||||
|
|
||||||
|
> This blockquote contains an image: 
|
||||||
|
|
||||||
|
## Relative and Absolute URLs
|
||||||
|
|
||||||
|
Links can use both relative and absolute URLs.
|
||||||
|
|
||||||
|
This is a [relative link](../page.html).
|
||||||
|
|
||||||
|
This is an [absolute link](https://example.com/page.html).
|
||||||
|
|
||||||
|
This is a [protocol-relative link](//example.com/page.html).
|
||||||
|
|
||||||
|
## Links with Special Characters
|
||||||
|
|
||||||
|
Links can contain special characters, but URLs with spaces should be encoded.
|
||||||
|
|
||||||
|
This is a [link with encoded space](https://example.com/my%20page.html).
|
||||||
|
|
||||||
|
This is a [link with parentheses](https://example.com/page%28with%29parentheses.html).
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Empty Link Text
|
||||||
|
|
||||||
|
[](https://example.com)
|
||||||
|
|
||||||
|
### Link with Only Spaces
|
||||||
|
|
||||||
|
[ ](https://example.com)
|
||||||
|
|
||||||
|
### Image with Empty Alt Text
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Image with Only Spaces in Alt Text
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Link Without URL
|
||||||
|
|
||||||
|
[Link text]()
|
||||||
|
|
||||||
|
### Reference Link Without Definition
|
||||||
|
|
||||||
|
This is a [broken reference link][broken].
|
||||||
|
|
||||||
|
### Very Long URLs
|
||||||
|
|
||||||
|
This is a [link with a very long URL](https://example.com/very/long/path/to/a/resource/that/extends/beyond/normal/width/and/tests/how/the/renderer/handles/extended/urls.html).
|
||||||
|
|
||||||
|
### Links with Numbers
|
||||||
|
|
||||||
|
[Link 123](https://example.com)
|
||||||
|
|
||||||
|
[123 Link](https://example.com)
|
||||||
|
|
||||||
|
### Images with Special Characters in Alt Text
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### URL Encoding
|
||||||
|
|
||||||
|
For compatibility, encode spaces in URLs with `%20`.
|
||||||
|
|
||||||
|
✅ [Correct link](https://example.com/my%20page.html)
|
||||||
|
|
||||||
|
❌ [Incorrect link](https://example.com/my page.html)
|
||||||
|
|
||||||
|
### Parentheses in URLs
|
||||||
|
|
||||||
|
Encode opening parenthesis as `%28` and closing parenthesis as `%29`.
|
||||||
|
|
||||||
|
✅ [Correct link](https://example.com/page%28with%29parentheses.html)
|
||||||
|
|
||||||
|
❌ [Incorrect link](https://example.com/page(with)parentheses.html)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-links-and-images.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-links-and-images.md)
|
||||||
|
|
||||||
249
test/markdown/basic-lists.md
Normal file
249
test/markdown/basic-lists.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# Basic Lists Test
|
||||||
|
|
||||||
|
This file tests ordered and unordered list syntax in markdown.
|
||||||
|
|
||||||
|
## Unordered Lists
|
||||||
|
|
||||||
|
Unordered lists are created using hyphens (`-`), asterisks (`*`), or plus signs (`+`) followed by a space.
|
||||||
|
|
||||||
|
- First item
|
||||||
|
- Second item
|
||||||
|
- Third item
|
||||||
|
|
||||||
|
* First item
|
||||||
|
* Second item
|
||||||
|
* Third item
|
||||||
|
|
||||||
|
+ First item
|
||||||
|
+ Second item
|
||||||
|
+ Third item
|
||||||
|
|
||||||
|
## Ordered Lists
|
||||||
|
|
||||||
|
Ordered lists are created using numbers followed by a period and a space.
|
||||||
|
|
||||||
|
1. First item
|
||||||
|
2. Second item
|
||||||
|
3. Third item
|
||||||
|
|
||||||
|
## List Items with Multiple Paragraphs
|
||||||
|
|
||||||
|
List items can contain multiple paragraphs by indenting subsequent paragraphs.
|
||||||
|
|
||||||
|
- First item
|
||||||
|
|
||||||
|
This is a second paragraph in the first item.
|
||||||
|
|
||||||
|
- Second item
|
||||||
|
|
||||||
|
This is a second paragraph in the second item.
|
||||||
|
|
||||||
|
This is a third paragraph in the second item.
|
||||||
|
|
||||||
|
## Nested Lists
|
||||||
|
|
||||||
|
Lists can be nested by indenting list items.
|
||||||
|
|
||||||
|
- First level item
|
||||||
|
- Second level item
|
||||||
|
- Another second level item
|
||||||
|
- Back to first level
|
||||||
|
|
||||||
|
1. First ordered item
|
||||||
|
- Nested unordered item
|
||||||
|
- Another nested unordered item
|
||||||
|
2. Second ordered item
|
||||||
|
- Nested unordered item
|
||||||
|
- Third level item
|
||||||
|
|
||||||
|
- Unordered item
|
||||||
|
1. Nested ordered item
|
||||||
|
2. Another nested ordered item
|
||||||
|
- Another unordered item
|
||||||
|
|
||||||
|
## Lists with Formatting
|
||||||
|
|
||||||
|
List items can contain inline formatting like bold, italic, and code.
|
||||||
|
|
||||||
|
- Item with **bold text**
|
||||||
|
- Item with *italic text*
|
||||||
|
- Item with ***bold and italic text***
|
||||||
|
- Item with `code text`
|
||||||
|
- Item with **bold**, *italic*, and `code` together
|
||||||
|
|
||||||
|
1. Ordered item with **bold text**
|
||||||
|
2. Ordered item with *italic text*
|
||||||
|
3. Ordered item with `code text`
|
||||||
|
|
||||||
|
## Lists with Links
|
||||||
|
|
||||||
|
List items can contain links.
|
||||||
|
|
||||||
|
- Item with [inline link](https://example.com)
|
||||||
|
- Item with [reference link][ref]
|
||||||
|
|
||||||
|
[ref]: https://example.com
|
||||||
|
|
||||||
|
1. Ordered item with [link](https://example.com)
|
||||||
|
2. Another ordered item with [link](https://example.com)
|
||||||
|
|
||||||
|
## Lists with Code
|
||||||
|
|
||||||
|
List items can contain inline code and code blocks.
|
||||||
|
|
||||||
|
- Item with `inline code`
|
||||||
|
- Item with code block:
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block here
|
||||||
|
More code
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Ordered item with `inline code`
|
||||||
|
2. Ordered item with code block:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function example() {
|
||||||
|
return "Hello";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lists with Blockquotes
|
||||||
|
|
||||||
|
List items can contain blockquotes.
|
||||||
|
|
||||||
|
- Item with blockquote:
|
||||||
|
|
||||||
|
> This is a blockquote inside a list item.
|
||||||
|
|
||||||
|
1. Ordered item with blockquote:
|
||||||
|
|
||||||
|
> This is a blockquote inside an ordered list item.
|
||||||
|
|
||||||
|
## Lists with Other Lists
|
||||||
|
|
||||||
|
Lists can contain other lists as nested items.
|
||||||
|
|
||||||
|
- First item
|
||||||
|
- Nested unordered list
|
||||||
|
- Deeper nesting
|
||||||
|
- Another nested item
|
||||||
|
- Second item
|
||||||
|
1. Nested ordered list
|
||||||
|
2. Another ordered item
|
||||||
|
- Even deeper nesting
|
||||||
|
|
||||||
|
## Ordered Lists with Different Start Numbers
|
||||||
|
|
||||||
|
Ordered lists can start with any number, but markdown processors typically normalize them.
|
||||||
|
|
||||||
|
1. First item
|
||||||
|
2. Second item
|
||||||
|
3. Third item
|
||||||
|
|
||||||
|
5. Starting at five
|
||||||
|
6. Second item
|
||||||
|
7. Third item
|
||||||
|
|
||||||
|
10. Starting at ten
|
||||||
|
11. Second item
|
||||||
|
12. Third item
|
||||||
|
|
||||||
|
## Mixed List Types
|
||||||
|
|
||||||
|
You can mix ordered and unordered lists at the same level.
|
||||||
|
|
||||||
|
- Unordered item
|
||||||
|
- Another unordered item
|
||||||
|
|
||||||
|
1. Ordered item
|
||||||
|
2. Another ordered item
|
||||||
|
|
||||||
|
- Back to unordered
|
||||||
|
|
||||||
|
## Long List Items
|
||||||
|
|
||||||
|
List items can contain extended text that wraps across multiple lines.
|
||||||
|
|
||||||
|
- This is a very long list item that contains a substantial amount of text. It demonstrates how list items handle extended content that might wrap across multiple visual lines in the rendered output. The list item should maintain proper formatting and indentation.
|
||||||
|
|
||||||
|
- Another long item with multiple sentences. Each sentence flows naturally into the next. The entire item should render as a cohesive unit with appropriate line wrapping based on the container width.
|
||||||
|
|
||||||
|
## Lists with Special Characters
|
||||||
|
|
||||||
|
List items can contain special characters and punctuation.
|
||||||
|
|
||||||
|
- Item with numbers: 123, 456, 789
|
||||||
|
- Item with symbols: !@#$%^&*()
|
||||||
|
- Item with quotes: "Hello" and 'World'
|
||||||
|
- Item with parentheses (like this) and brackets [like this]
|
||||||
|
|
||||||
|
1. Ordered item with numbers: 123
|
||||||
|
2. Ordered item with symbols: !@#$%^&*()
|
||||||
|
3. Ordered item with quotes: "Hello"
|
||||||
|
|
||||||
|
## Empty List Items
|
||||||
|
|
||||||
|
An empty list item can be created, though it may not render visibly.
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
1.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### List with Only Spaces
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
### List Item with Trailing Spaces
|
||||||
|
|
||||||
|
- Item with trailing spaces.
|
||||||
|
|
||||||
|
### Very Short List Items
|
||||||
|
|
||||||
|
- A
|
||||||
|
- B
|
||||||
|
- C
|
||||||
|
|
||||||
|
### List with Only Special Characters
|
||||||
|
|
||||||
|
- !@#$%^&*()
|
||||||
|
- !@#$%^&*()
|
||||||
|
|
||||||
|
### List Marker Without Space
|
||||||
|
|
||||||
|
-This might not render correctly in some processors.
|
||||||
|
|
||||||
|
1.This might not render correctly in some processors.
|
||||||
|
|
||||||
|
### Single Item Lists
|
||||||
|
|
||||||
|
- Only one item
|
||||||
|
|
||||||
|
1. Only one item
|
||||||
|
|
||||||
|
### Lists with Many Items
|
||||||
|
|
||||||
|
This tests how lists handle a larger number of items.
|
||||||
|
|
||||||
|
1. First item
|
||||||
|
2. Second item
|
||||||
|
3. Third item
|
||||||
|
4. Fourth item
|
||||||
|
5. Fifth item
|
||||||
|
6. Sixth item
|
||||||
|
7. Seventh item
|
||||||
|
8. Eighth item
|
||||||
|
9. Ninth item
|
||||||
|
10. Tenth item
|
||||||
|
11. Eleventh item
|
||||||
|
12. Twelfth item
|
||||||
|
13. Thirteenth item
|
||||||
|
14. Fourteenth item
|
||||||
|
15. Fifteenth item
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-lists.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-lists.md)
|
||||||
|
|
||||||
156
test/markdown/basic-paragraphs-line-breaks.md
Normal file
156
test/markdown/basic-paragraphs-line-breaks.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Basic Paragraphs and Line Breaks Test
|
||||||
|
|
||||||
|
This file tests paragraph separation and line break syntax in markdown.
|
||||||
|
|
||||||
|
## Paragraphs
|
||||||
|
|
||||||
|
Paragraphs are created by separating blocks of text with blank lines. A paragraph is one or more consecutive lines of text separated by blank lines.
|
||||||
|
|
||||||
|
This is the first paragraph. It contains multiple sentences. Each sentence flows naturally into the next. The paragraph continues until a blank line appears.
|
||||||
|
|
||||||
|
This is the second paragraph. It is separated from the first paragraph by a blank line. Paragraphs should render as distinct blocks of text with appropriate spacing between them.
|
||||||
|
|
||||||
|
This is the third paragraph. It demonstrates that multiple paragraphs can appear consecutively, each separated by a blank line.
|
||||||
|
|
||||||
|
## Single Line Paragraphs
|
||||||
|
|
||||||
|
A paragraph can consist of a single line of text.
|
||||||
|
|
||||||
|
This is a single-line paragraph.
|
||||||
|
|
||||||
|
This is another single-line paragraph.
|
||||||
|
|
||||||
|
## Paragraphs with Multiple Sentences
|
||||||
|
|
||||||
|
Paragraphs can contain multiple sentences. Each sentence ends with appropriate punctuation. The sentences flow together as a cohesive unit. This paragraph demonstrates that natural paragraph structure is preserved.
|
||||||
|
|
||||||
|
## Line Breaks
|
||||||
|
|
||||||
|
To create a line break within a paragraph, end a line with two or more spaces followed by a return.
|
||||||
|
|
||||||
|
This is the first line.
|
||||||
|
This is the second line created with trailing spaces.
|
||||||
|
|
||||||
|
This demonstrates that line breaks create a new line within the same paragraph, rather than starting a new paragraph.
|
||||||
|
|
||||||
|
## HTML Line Breaks
|
||||||
|
|
||||||
|
If your markdown processor supports HTML, you can use the `<br>` tag for line breaks.
|
||||||
|
|
||||||
|
This is the first line.<br>
|
||||||
|
This is the second line created with an HTML break tag.
|
||||||
|
|
||||||
|
This is another line.<br>
|
||||||
|
And another line after the break.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Don't Indent Paragraphs
|
||||||
|
|
||||||
|
Paragraphs should not be indented with spaces or tabs unless they are part of a list.
|
||||||
|
|
||||||
|
This is a correctly formatted paragraph without indentation.
|
||||||
|
|
||||||
|
This paragraph is incorrectly indented with spaces.
|
||||||
|
|
||||||
|
This paragraph is correctly formatted again.
|
||||||
|
|
||||||
|
### Blank Lines Between Paragraphs
|
||||||
|
|
||||||
|
Always use blank lines to separate paragraphs for compatibility.
|
||||||
|
|
||||||
|
This paragraph is correctly separated.
|
||||||
|
|
||||||
|
This paragraph is also correctly separated.
|
||||||
|
|
||||||
|
Without a blank line, this might not render as a separate paragraph.
|
||||||
|
This text might be treated as part of the previous paragraph.
|
||||||
|
|
||||||
|
## Multiple Line Breaks
|
||||||
|
|
||||||
|
Multiple line breaks within a paragraph can be created using trailing spaces or HTML breaks.
|
||||||
|
|
||||||
|
Line one.
|
||||||
|
Line two.
|
||||||
|
Line three.
|
||||||
|
|
||||||
|
Line one.<br>
|
||||||
|
Line two.<br>
|
||||||
|
Line three.
|
||||||
|
|
||||||
|
## Paragraphs with Formatting
|
||||||
|
|
||||||
|
Paragraphs can contain inline formatting like bold, italic, and code.
|
||||||
|
|
||||||
|
This paragraph contains **bold text** and *italic text* and `code text`.
|
||||||
|
|
||||||
|
This paragraph demonstrates that formatting works correctly within paragraph boundaries.
|
||||||
|
|
||||||
|
## Paragraphs with Links
|
||||||
|
|
||||||
|
Paragraphs can contain links and other inline elements.
|
||||||
|
|
||||||
|
This paragraph contains a [link to example.com](https://example.com) and another [reference link][ref].
|
||||||
|
|
||||||
|
[ref]: https://example.com
|
||||||
|
|
||||||
|
## Long Paragraphs
|
||||||
|
|
||||||
|
This paragraph contains a substantial amount of text to test how the markdown processor handles longer paragraphs. It includes multiple sentences that flow together naturally. The paragraph should render as a single cohesive block of text with appropriate line wrapping based on the container width. This tests that paragraph rendering works correctly even with extended content that might wrap across multiple visual lines in the rendered output.
|
||||||
|
|
||||||
|
## Paragraphs with Special Characters
|
||||||
|
|
||||||
|
Paragraphs can contain various special characters and punctuation marks.
|
||||||
|
|
||||||
|
This paragraph has numbers: 123, 456, 789.
|
||||||
|
|
||||||
|
This paragraph has symbols: !@#$%^&*().
|
||||||
|
|
||||||
|
This paragraph has quotes: "Hello" and 'World'.
|
||||||
|
|
||||||
|
This paragraph has parentheses (like this) and brackets [like this].
|
||||||
|
|
||||||
|
## Empty Paragraphs
|
||||||
|
|
||||||
|
An empty line creates a paragraph break, but multiple empty lines should still create a single paragraph break.
|
||||||
|
|
||||||
|
Paragraph before empty lines.
|
||||||
|
|
||||||
|
Paragraph after empty lines.
|
||||||
|
|
||||||
|
## Paragraphs with Code
|
||||||
|
|
||||||
|
Paragraphs can contain inline code and code blocks.
|
||||||
|
|
||||||
|
This paragraph contains `inline code` within the text flow.
|
||||||
|
|
||||||
|
This paragraph appears before a code block.
|
||||||
|
|
||||||
|
```
|
||||||
|
Code block here
|
||||||
|
```
|
||||||
|
|
||||||
|
This paragraph appears after a code block.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Paragraph with Only Whitespace
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Paragraph with Trailing Spaces
|
||||||
|
|
||||||
|
This paragraph has trailing spaces.
|
||||||
|
|
||||||
|
### Very Short Paragraph
|
||||||
|
|
||||||
|
A.
|
||||||
|
|
||||||
|
### Paragraph with Only Special Characters
|
||||||
|
|
||||||
|
!@#$%^&*()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Source:** [basic-paragraphs-line-breaks.md](https://github.com/dergigi/boris/tree/master/test/markdown/basic-paragraphs-line-breaks.md)
|
||||||
|
|
||||||
166
test/markdown/tables.md
Normal file
166
test/markdown/tables.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Markdown Tables Test
|
||||||
|
|
||||||
|
This file contains various markdown table examples to test table parsing and rendering.
|
||||||
|
|
||||||
|
## Basic Table
|
||||||
|
|
||||||
|
This is a simple two-column table with two data rows. It tests basic table structure and rendering without any special formatting or alignment.
|
||||||
|
|
||||||
|
| Column 1 | Column 2 |
|
||||||
|
| ------------- | ------------- |
|
||||||
|
| Cell 1, Row 1 | Cell 2, Row 1 |
|
||||||
|
| Cell 1, Row 2 | Cell 2, Row 2 |
|
||||||
|
|
||||||
|
## Table with Alignment
|
||||||
|
|
||||||
|
This table demonstrates text alignment options in markdown tables. The first column is left-aligned (default), the second is centered using `:---:`, and the third is right-aligned using `---:`. This tests that the CSS alignment rules work correctly.
|
||||||
|
|
||||||
|
| Left | Centered | Right |
|
||||||
|
| :----------- | :--------------: | -------------------------: |
|
||||||
|
| This is left | Text is centered | And this is right-aligned |
|
||||||
|
| More text | Even more text | And even more to the right |
|
||||||
|
|
||||||
|
## Table with Formatting
|
||||||
|
|
||||||
|
This table contains various markdown formatting within cells: italic text using asterisks, bold text using double asterisks, and inline code using backticks. This tests that formatting is preserved and rendered correctly within table cells.
|
||||||
|
|
||||||
|
| Name | Location | Food |
|
||||||
|
| ------- | ------------ | ------- |
|
||||||
|
| *Alice* | **New York** | `Pizza` |
|
||||||
|
| Bob | Paris | Crepes |
|
||||||
|
|
||||||
|
## Table with Links
|
||||||
|
|
||||||
|
This table includes markdown links within cells. It tests that hyperlinks are properly rendered and clickable within table cells, and that link styling matches the app's theme.
|
||||||
|
|
||||||
|
| Name | Website | Description |
|
||||||
|
| ----- | -------------------------- | --------------------- |
|
||||||
|
| Alice | [GitHub](https://github.com) | Code repository |
|
||||||
|
| Bob | [Nostr](https://nostr.com) | Decentralized network |
|
||||||
|
|
||||||
|
## Table with Code Blocks
|
||||||
|
|
||||||
|
This table contains inline code examples in cells. It tests that code formatting (monospace font, background, borders) is properly applied within table cells and doesn't conflict with table styling.
|
||||||
|
|
||||||
|
| Language | Example |
|
||||||
|
| -------- | -------------------------- |
|
||||||
|
| Python | `print("Hello, World!")` |
|
||||||
|
| JavaScript | `console.log("Hello")` |
|
||||||
|
| SQL | `SELECT * FROM users` |
|
||||||
|
|
||||||
|
## Wide Table (Testing Horizontal Scroll)
|
||||||
|
|
||||||
|
This table has eight columns to test horizontal scrolling behavior on mobile devices and smaller screens. The table should allow users to scroll horizontally to view all columns while maintaining proper styling and readability.
|
||||||
|
|
||||||
|
| Column 1 | Column 2 | Column 3 | Column 4 | Column 5 | Column 6 | Column 7 | Column 8 |
|
||||||
|
| -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- |
|
||||||
|
| Data 1 | Data 2 | Data 3 | Data 4 | Data 5 | Data 6 | Data 7 | Data 8 |
|
||||||
|
| More | Content | Here | To | Test | Scrolling| Behavior | Mobile |
|
||||||
|
|
||||||
|
## Table with Mixed Content
|
||||||
|
|
||||||
|
This table combines various content types: currency values, emoji indicators, and descriptive text. It tests how different content types render together within table cells and ensures proper spacing and alignment.
|
||||||
|
|
||||||
|
| Item | Price | Status | Notes |
|
||||||
|
| ---- | ----- | ------ | ------------------------------ |
|
||||||
|
| Apple | $1.00 | ✅ In stock | Fresh from the farm |
|
||||||
|
| Banana | $0.50 | ⚠️ Low stock | Last few left |
|
||||||
|
| Orange | $1.25 | ❌ Out of stock | Coming next week |
|
||||||
|
|
||||||
|
## Table with Empty Cells
|
||||||
|
|
||||||
|
This table contains empty cells to test how the table styling handles missing data. Empty cells should still maintain proper borders and spacing, ensuring the table structure remains intact.
|
||||||
|
|
||||||
|
| Name | Email | Phone |
|
||||||
|
| ---- | ----- | ----- |
|
||||||
|
| Alice | alice@example.com | |
|
||||||
|
| Bob | | 555-1234 |
|
||||||
|
| Charlie | charlie@example.com | 555-5678 |
|
||||||
|
|
||||||
|
## Table with Long Text
|
||||||
|
|
||||||
|
This table tests text wrapping behavior with varying column widths. The third column contains a long paragraph that should wrap to multiple lines within the cell while maintaining proper padding and readability. This is especially important for responsive design.
|
||||||
|
|
||||||
|
| Short | Medium Length Column | Very Long Column That Contains A Lot Of Text And Should Wrap Properly |
|
||||||
|
| ----- | -------------------- | -------------------------------------------------------------------- |
|
||||||
|
| A | This is medium text | This is a very long piece of text that should wrap to multiple lines when displayed in the table cell. It should maintain proper formatting and readability. |
|
||||||
|
|
||||||
|
## Table with Numbers
|
||||||
|
|
||||||
|
This table contains 21 rows of ranked data with numeric scores and percentages. It's useful for testing row striping, scrolling behavior with longer tables, and ensuring that numeric alignment and formatting remain consistent throughout a larger dataset.
|
||||||
|
|
||||||
|
| Rank | Name | Score | Percentage |
|
||||||
|
| ---- | ---- | ----- | ---------- |
|
||||||
|
| 1 | Alice | 95 | 95% |
|
||||||
|
| 2 | Bob | 87 | 87% |
|
||||||
|
| 3 | Charlie | 82 | 82% |
|
||||||
|
| 4 | David | 78 | 78% |
|
||||||
|
| 5 | Emma | 75 | 75% |
|
||||||
|
| 6 | Frank | 72 | 72% |
|
||||||
|
| 7 | Grace | 70 | 70% |
|
||||||
|
| 8 | Henry | 68 | 68% |
|
||||||
|
| 9 | Ivy | 65 | 65% |
|
||||||
|
| 10 | Jack | 63 | 63% |
|
||||||
|
| 11 | Kate | 60 | 60% |
|
||||||
|
| 12 | Liam | 58 | 58% |
|
||||||
|
| 13 | Mia | 55 | 55% |
|
||||||
|
| 14 | Noah | 53 | 53% |
|
||||||
|
| 15 | Olivia | 50 | 50% |
|
||||||
|
| 16 | Paul | 48 | 48% |
|
||||||
|
| 17 | Quinn | 45 | 45% |
|
||||||
|
| 18 | Ryan | 43 | 43% |
|
||||||
|
| 19 | Sarah | 40 | 40% |
|
||||||
|
| 20 | Tom | 38 | 38% |
|
||||||
|
| 21 | Uma | 35 | 35% |
|
||||||
|
|
||||||
|
## Table with Special Characters
|
||||||
|
|
||||||
|
This table contains escaped special characters that have meaning in markdown syntax. It tests that these characters are properly escaped and displayed as literal characters rather than being interpreted as markdown syntax.
|
||||||
|
|
||||||
|
| Symbol | Name | Usage |
|
||||||
|
| ------ | ---- | ----- |
|
||||||
|
| `\|` | Pipe | Used in markdown tables |
|
||||||
|
| `\*` | Asterisk | Used for bold/italic |
|
||||||
|
| `\#` | Hash | Used for headings |
|
||||||
|
|
||||||
|
## Table with Headers Only
|
||||||
|
|
||||||
|
This table contains only header rows with no data rows. It tests edge case handling for tables without content, ensuring that the header styling is still applied correctly even when there's no body content.
|
||||||
|
|
||||||
|
| Header 1 | Header 2 | Header 3 |
|
||||||
|
| -------- | -------- | -------- |
|
||||||
|
|
||||||
|
## Single Column Table
|
||||||
|
|
||||||
|
This is a minimal table with only one column. It tests how table styling handles narrow tables and ensures that single-column layouts are properly formatted with appropriate borders and spacing.
|
||||||
|
|
||||||
|
| Item |
|
||||||
|
| ---- |
|
||||||
|
| First |
|
||||||
|
| Second |
|
||||||
|
| Third |
|
||||||
|
|
||||||
|
## A Table from a Real Article
|
||||||
|
|
||||||
|
This one is from [Bitcoin is Time](https://read.withboris.com/a/naddr1qq8ky6t5vdhkjm3dd9ej6arfd4jsygrwg6zz9hahfftnsup23q3mnv5pdz46hpj4l2ktdpfu6rhpthhwjvpsgqqqw4rsdan6ej) which broke and is the reason why this document exists.
|
||||||
|
|
||||||
|
| Clock | Tick Frequency |
|
||||||
|
| --------------------------|-----------------------------------------|
|
||||||
|
| Grandfather's clock | ~0.5 Hz |
|
||||||
|
| Metronome | ~0.67 Hz to ~4.67 Hz |
|
||||||
|
| Quartz watch | 32768 Hz |
|
||||||
|
| Caesium-133 atomic clock | 9,192,631,770 Hz |
|
||||||
|
| Bitcoin | 1 block (0.00000192901 Hz* to ∞ Hz**) |
|
||||||
|
|
||||||
|
\* first block (6 days)
|
||||||
|
\*\* timestamps between blocks can show a negative delta
|
||||||
|
|
||||||
|
## Table with Nested Formatting
|
||||||
|
|
||||||
|
This table demonstrates complex nested formatting combinations within cells, including bold and italic text together, code blocks containing links, and strikethrough text. It tests that multiple formatting types can coexist properly within table cells.
|
||||||
|
|
||||||
|
| Description | Example |
|
||||||
|
| ----------- | ------- |
|
||||||
|
| Bold and italic | ***Important*** |
|
||||||
|
| Code and link | `[Click here](https://example.com)` |
|
||||||
|
| Strikethrough | ~~Old price~~ |
|
||||||
21
vercel.json
21
vercel.json
@@ -1,14 +1,23 @@
|
|||||||
{
|
{
|
||||||
|
"version": 2,
|
||||||
|
"functions": {
|
||||||
|
"api/article-og.ts": {
|
||||||
|
"maxDuration": 10
|
||||||
|
},
|
||||||
|
"api/article-og-refresh.ts": {
|
||||||
|
"maxDuration": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
{
|
{
|
||||||
"source": "/a/:naddr",
|
"source": "/a/:naddr",
|
||||||
|
"destination": "/index.html",
|
||||||
"has": [
|
"has": [
|
||||||
{
|
{ "type": "query", "key": "_spa", "value": "1" }
|
||||||
"type": "header",
|
]
|
||||||
"key": "user-agent",
|
},
|
||||||
"value": ".*(bot|crawl|spider|slurp|facebook|twitter|linkedin|whatsapp|telegram|slack|discord|preview).*"
|
{
|
||||||
}
|
"source": "/a/:naddr",
|
||||||
],
|
|
||||||
"destination": "/api/article-og?naddr=:naddr"
|
"destination": "/api/article-og?naddr=:naddr"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user