Browse Source

citation embedding

imwald
Silberengel 3 months ago
parent
commit
4df079e552
  1. 115
      CITATION_TEST_CONTENT.adoc
  2. 80
      CITATION_TEST_README.md
  3. 14
      src/components/EmbeddedCitation/index.tsx
  4. 390
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  5. 21
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  6. 6
      src/components/Note/index.tsx

115
CITATION_TEST_CONTENT.adoc

@ -0,0 +1,115 @@ @@ -0,0 +1,115 @@
= Test Article: Citation Embedding Examples
Author Name
2024-01-15
This article demonstrates all citation types and display methods that can be embedded in AsciiDoc articles.
IMPORTANT: Replace all placeholder nevent IDs with actual citation event IDs from your Nostr relays.
== Citation Format
All citations use the format: `[[citation::TYPE::NEVENT_ID]]`
The TYPE can be:
- `inline` - renders inline within text
- `foot` - creates a footnote
- `foot-end` - creates a footnote that links to an endnote
- `end` - appears at the end in references section
- `quote` - block-level citation card
- `prompt-inline` - inline prompt citation
- `prompt-end` - prompt citation in references section
== Internal Citations (Kind 30)
Internal citations reference other Nostr events.
=== Inline Internal Citation
Here's an inline citation: [[citation::inline::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsqgqpl98djyt2eln4uy4dlx2l6a6eyum8acgqz3vfnqptkx54suyyn5u59v88]]
You can have multiple inline citations in one sentence: The first citation [[citation::inline::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyry7njqtmu366utn3422xt84hv6pg3h5vsac602xne63hw7hmrjew8x37w3]] and the second citation [[citation::inline::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyr977llj62ttqp3zw5dhdp3mdswng5ge7hfgdsz2vc7f5w5889w857hmzhr]] both reference Nostr events.
=== Footnote Internal Citation
This sentence has a footnote citation.footnote: [[citation::foot::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsqgqpl98djyt2eln4uy4dlx2l6a6eyum8acgqz3vfnqptkx54suyyn5u59v88]]
=== Endnote Internal Citation
This paragraph uses an endnote citation that will appear in the references section [[citation::end::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyry7njqtmu366utn3422xt84hv6pg3h5vsac602xne63hw7hmrjew8x37w3]].
=== Block Quote Internal Citation
For block-level display of citations:
[[citation::quote::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyry7njqtmu366utn3422xt84hv6pg3h5vsac602xne63hw7hmrjew8x37w3]]
== External Web Citations (Kind 31)
External citations reference web resources.
=== Inline External Citation
Here's an inline external citation: [[citation::inline::nevent1qvzqqqqqrupzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyqsdgj88q2tswy2stc9p5xfaf200kr9le8m75se084upkrqkex9yvwk8faj]]
=== Footnote-End External Citation
This creates a footnote that links to an endnote.footnote: [[[citation::foot-end::nevent1qvzqqqqqrupzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyqsdgj88q2tswy2stc9p5xfaf200kr9le8m75se084upkrqkex9yvwk8faj]]]
=== Endnote External Citation
This paragraph references a web source [[citation::end::nevent1qvzqqqqqrupzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyqsdgj88q2tswy2stc9p5xfaf200kr9le8m75se084upkrqkex9yvwk8faj]].
== Hardcopy Citations (Kind 32)
Hardcopy citations reference printed materials like books and journals.
=== Inline Hardcopy Citation
Here's an inline hardcopy citation: [[citation::inline::nevent1qvzqqqqqyqpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcprfmhxue69uhkvun9v4kxz7fwwdhhvcnfwshxsmmnwshsqgyzg2dv4w5dpsalmm28qvn3t0gsl09u0m5ar4jfupzkrt5t0fh2vgzych2c]]
=== Endnote Hardcopy Citation
This references a book [[citation::end::nevent1qvzqqqqqyqpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcprfmhxue69uhkvun9v4kxz7fwwdhhvcnfwshxsmmnwshsqgyzg2dv4w5dpsalmm28qvn3t0gsl09u0m5ar4jfupzkrt5t0fh2vgzych2c]].
=== Block Quote Hardcopy Citation
For important book references:
[[citation::quote::nevent1qvzqqqqqyqpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcprfmhxue69uhkvun9v4kxz7fwwdhhvcnfwshxsmmnwshsqgyzg2dv4w5dpsalmm28qvn3t0gsl09u0m5ar4jfupzkrt5t0fh2vgzych2c]]
== Prompt Citations (Kind 33)
Prompt citations reference AI/LLM interactions.
=== Inline Prompt Citation
Here's an inline prompt citation: [[citation::prompt-inline::nevent1qvzqqqqqyypzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyprf7ddefkkvdedvredu83pqqn7payvvcnrqp2s72zrx823x0wpezqzpst2]]
=== Endnote Prompt Citation
This paragraph discusses AI-generated content [[citation::prompt-end::nevent1qvzqqqqqyypzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyprf7ddefkkvdedvredu83pqqn7payvvcnrqp2s72zrx823x0wpezqzpst2]].
== Mixed Citation Usage
You can mix different citation types in the same paragraph. For example, this sentence references both an external source [[citation::inline::nevent1qvzqqqqqrupzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyqsdgj88q2tswy2stc9p5xfaf200kr9le8m75se084upkrqkex9yvwk8faj]] and an internal Nostr event [[citation::inline::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsqgqpl98djyt2eln4uy4dlx2l6a6eyum8acgqz3vfnqptkx54suyyn5u59v88]].
Here's a combination with footnotes and endnotes: This sentence has a footnote.footnote: [[[citation::foot::nevent1qqst8cju0m99ner9ucsu0fw3p0p4v8x0nctvmfx03u67welhnevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyry7njqtmu366utn3422xt84hv6pg3h5vsac602xne63hw7hmrjew8x37w34z5h27wwp4m8x7ttswf0lk2wr8gs4lw9z34vamnwvaz7tmwdaehgu3wvfhkummwvaz7tmjv4kxz7fwdehk6k6]]] and this sentence references an endnote [[citation::end::nevent1qvzqqqqqrcpzqez7hqy2ca5f7z94zslmu7489zd645hrhurfeqwj5g4q6we438qcqydhwumn8ghj7mmjd3uj6un9d3shjtnfd4mkzmry9ejh2tcpr3mhxue69uhhg6r9vd5hgctyv4kzumn0wd68yvfwvdhk6tcqyry7njqtmu366utn3422xt84hv6pg3h5vsac602xne63hw7hmrjew8x37w3]].
== Citation Display Types Summary
. *Inline* (`inline`, `prompt-inline`): Renders inline within the text as clickable citation text
. *Footnotes* (`foot`): Creates superscript numbers that link to footnotes
. *Foot-End* (`foot-end`): Creates footnotes that link to endnotes at the end
. *Endnotes* (`end`, `prompt-end`): References appear at the end of the document in a references section
. *Quotes* (`quote`): Block-level citation cards for emphasis
== How to Use This Test Document
. Replace all placeholder `nevent1qq...` IDs with actual citation event IDs from your Nostr relays
. Create citations using the Post Editor for kinds 30, 31, 32, and 33
. Copy the nevent ID (or note ID) of your created citation event
. Replace the placeholder IDs in this document
. Publish as an AsciiDoc article (kind 30818) or Wiki Article (kind 30817)
. Verify all citation types render correctly
NOTE: All citation event IDs in this document are placeholder examples. You must replace them with real citation event IDs to test properly.

80
CITATION_TEST_README.md

@ -0,0 +1,80 @@ @@ -0,0 +1,80 @@
# Citation Test Content Guide
## File: `CITATION_TEST_CONTENT.adoc`
This file contains comprehensive test examples for embedding all citation types in AsciiDoc articles.
## Citation Format
All citations use plain format (passthrough markers are added automatically during processing):
```
[[citation::TYPE::NEVENT_ID]]
```
## Citation Types Tested
### 1. Internal Citations (Kind 30)
- `inline` - Inline citation within text
- `foot` - Footnote citation
- `end` - Endnote in references section
- `quote` - Block-level citation card
### 2. External Web Citations (Kind 31)
- `inline` - Inline citation
- `foot-end` - Footnote linking to endnote
- `end` - Endnote in references
### 3. Hardcopy Citations (Kind 32)
- `inline` - Inline citation
- `end` - Endnote in references
- `quote` - Block-level citation card
### 4. Prompt Citations (Kind 33)
- `prompt-inline` - Inline prompt citation
- `prompt-end` - Prompt citation in references section
## How to Use
1. **Create Citation Events**: Use the Post Editor to create citations:
- Internal Citation (kind 30)
- External Citation (kind 31)
- Hardcopy Citation (kind 32)
- Prompt Citation (kind 33)
2. **Get Citation IDs**: After creating citations, copy their nevent IDs (or note IDs)
3. **Replace Placeholders**: In the test document, replace all `nevent1qq...` placeholder IDs with your actual citation event IDs
4. **Test in Article**:
- Create a new AsciiDoc article (kind 30818) or Wiki Article (kind 30817)
- Paste the test content (with real citation IDs)
- Publish and verify all citation types render correctly
## Citation Display Types
- **inline** / **prompt-inline**: Renders as clickable text inline
- **foot**: Creates superscript footnote numbers
- **foot-end**: Creates footnotes that link to endnotes
- **end** / **prompt-end**: Appears in References section at end
- **quote**: Block-level citation card for emphasis
## Testing Checklist
- [ ] Internal citations render inline
- [ ] Internal citations render as footnotes
- [ ] Internal citations appear in references section
- [ ] External citations render correctly
- [ ] Hardcopy citations render correctly
- [ ] Prompt citations render correctly (inline and end)
- [ ] Block quote citations display as cards
- [ ] Mixed citations in same paragraph work
- [ ] All citation types are clickable and navigate correctly
## Notes
- Citation IDs can be in format: `nevent1...`, `note1...`, or hex IDs
- All citations must exist on your Nostr relays to render properly
- Endnotes automatically collect at the end in a "References" section
- Footnotes appear at the bottom of the page/section

14
src/components/EmbeddedCitation/index.tsx

@ -10,23 +10,29 @@ interface EmbeddedCitationProps { @@ -10,23 +10,29 @@ interface EmbeddedCitationProps {
}
export default function EmbeddedCitation({ citationId, displayType = 'end', className }: EmbeddedCitationProps) {
// Strip all nostr: prefixes if present (handle cases like nostr:nostr:nevent1...)
let cleanId = citationId.trim()
while (cleanId.startsWith('nostr:')) {
cleanId = cleanId.substring(6) // Remove 'nostr:' prefix
}
// Try to decode as bech32 first
let eventId: string | null = null
try {
const decoded = nip19.decode(citationId)
const decoded = nip19.decode(cleanId)
if (decoded.type === 'nevent') {
const data = decoded.data as any
eventId = data.id || citationId
eventId = data.id || cleanId
} else if (decoded.type === 'note') {
eventId = decoded.data as string
} else {
// If it's not a note/nevent, use the original ID
eventId = citationId
eventId = cleanId
}
} catch {
// If decoding fails, assume it's already a hex ID
eventId = citationId
eventId = cleanId
}
const { event, isFetching } = useFetchEvent(eventId || '')

390
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -15,6 +15,8 @@ import Lightbox from 'yet-another-react-lightbox' @@ -15,6 +15,8 @@ import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded'
import EmbeddedCitation from '@/components/EmbeddedCitation'
import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { ReplyProvider } from '@/providers/ReplyProvider'
import Wikilink from '@/components/UniversalContent/Wikilink'
import { BookstrContent } from '@/components/Bookstr'
import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup'
@ -61,6 +63,7 @@ function convertMarkdownToAsciidoc(content: string): string { @@ -61,6 +63,7 @@ function convertMarkdownToAsciidoc(content: string): string {
// Do this early so they're protected from other markdown conversions
// naddr addresses can be 200+ characters, so we use + instead of specific length
// Also handle optional [] suffix (empty link text in AsciiDoc)
// Note: Citations are already protected in passthrough (+++...+++), so nostr: links inside them won't be processed
asciidoc = asciidoc.replace(/nostr:(npub1[a-z0-9]{58,}|nprofile1[a-z0-9]+|note1[a-z0-9]{58,}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)(\[\])?/g, (_match, bech32Id, emptyBrackets) => {
// Convert directly to AsciiDoc link format
// This will be processed later in HTML post-processing to render as React components
@ -358,6 +361,18 @@ export default function AsciidocArticle({ @@ -358,6 +361,18 @@ export default function AsciidocArticle({
return `+++BOOKSTR_MARKER:${cleanContent}:BOOKSTR_END+++`
})
// Protect citations by converting them to passthrough format
// Don't use [[...]] inside passthrough as AsciiDoc processes it - use a plain marker instead
content = content.replace(/\[\[citation::(end|foot|foot-end|inline|quote|prompt-end|prompt-inline)::([^\]]+)\]\]/g, (_match, citationType, citationId) => {
// Strip all nostr: prefixes if present (handle cases like nostr:nostr:nevent1...)
let cleanId = citationId.trim()
while (cleanId.startsWith('nostr:')) {
cleanId = cleanId.substring(6) // Remove 'nostr:' prefix
}
// Use a unique marker format that won't conflict with other content
return `+++CITATION_MARKER:${citationType}::${cleanId}:CITATION_END+++`
})
// Then protect regular wikilinks by converting them to passthrough format
// This prevents AsciiDoc from processing them and prevents URLs inside from being processed
content = content.replace(/\[\[([^\]]+)\]\]/g, (_match, linkContent) => {
@ -365,6 +380,10 @@ export default function AsciidocArticle({ @@ -365,6 +380,10 @@ export default function AsciidocArticle({
if (linkContent.startsWith('book::')) {
return _match
}
// Skip citations - they're already processed above
if (linkContent.startsWith('citation::')) {
return _match
}
// Convert to AsciiDoc passthrough format so it's preserved
return `+++WIKILINK:${linkContent}+++`
})
@ -640,6 +659,24 @@ export default function AsciidocArticle({ @@ -640,6 +659,24 @@ export default function AsciidocArticle({
// Note: Markdown is now converted to AsciiDoc in preprocessing,
// so post-processing markdown should not be necessary
// IMPORTANT: Process citations FIRST before any nostr: link processing
// This prevents nostr: links inside citations from being processed incorrectly
// Handle citation markers - convert passthrough markers to placeholders
// AsciiDoc passthrough +++CITATION_MARKER:type::id:CITATION_END+++ outputs CITATION_MARKER:type::id:CITATION_END in HTML
htmlString = htmlString.replace(/CITATION_MARKER:\s*(end|foot|foot-end|inline|quote|prompt-end|prompt-inline)::\s*(.+?)\s*:CITATION_END/g, (_match, citationType, citationId) => {
// Strip all nostr: prefixes if present (handle cases like nostr:nostr:nevent1...)
let cleanId = citationId.trim()
while (cleanId.startsWith('nostr:')) {
cleanId = cleanId.substring(6) // Remove 'nostr:' prefix
}
const escapedId = cleanId.replace(/"/g, '"').replace(/'/g, ''')
// Use inline element for inline citations and footnotes/endnotes (they need to be inline)
// Only block-level citations (quote) should use div
const isInline = citationType === 'inline' || citationType === 'prompt-inline' || citationType === 'foot' || citationType === 'foot-end' || citationType === 'end' || citationType === 'prompt-end'
const tag = isInline ? 'span' : 'div'
return `<${tag} data-citation="${escapedId}" data-citation-type="${citationType}" class="citation-placeholder${isInline ? ' inline' : ''}"></${tag}>`
})
// Post-process HTML to handle nostr: links
// Mentions (npub/nprofile) should be inline, events (note/nevent/naddr) should be block-level
// First, handle nostr: links in <a> tags (from AsciiDoc link: syntax)
@ -731,13 +768,6 @@ export default function AsciidocArticle({ @@ -731,13 +768,6 @@ export default function AsciidocArticle({
return `<div data-latex-block="${escaped}" class="latex-block-placeholder my-4"></div>`
})
// Handle citation markup: [[citation::type::nevent...]]
// AsciiDoc passthrough +++[[citation::type::nevent...]]+++ outputs just [[citation::type::nevent...]] in HTML
htmlString = htmlString.replace(/\[\[citation::(end|foot|foot-end|inline|quote|prompt-end|prompt-inline)::([^\]]+)\]\]/g, (_match, citationType, citationId) => {
const escapedId = citationId.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
return `<div data-citation="${escapedId}" data-citation-type="${citationType}" class="citation-placeholder"></div>`
})
// Handle bookstr markers - convert passthrough markers to placeholders
// AsciiDoc passthrough +++BOOKSTR_MARKER:...:BOOKSTR_END+++ outputs BOOKSTR_MARKER:...:BOOKSTR_END in HTML
// Match the delimited format to extract the exact content
@ -897,6 +927,9 @@ export default function AsciidocArticle({ @@ -897,6 +927,9 @@ export default function AsciidocArticle({
const reactRootsRef = useRef<Map<Element, Root>>(new Map())
// Track which placeholders have been processed to avoid re-processing
const processedPlaceholdersRef = useRef<Set<string>>(new Set())
// Track citations for footnotes and endnotes sections
const citationsRef = useRef<Array<{ id: string; type: string; citationId: string; index: number }>>([])
const citationIndexRef = useRef(0)
// Post-process rendered HTML to inject React components for nostr: links and handle hashtags
useEffect(() => {
@ -995,37 +1028,345 @@ export default function AsciidocArticle({ @@ -995,37 +1028,345 @@ export default function AsciidocArticle({
})
// Process citations - replace placeholders with React components
const citationPlaceholders = contentRef.current.querySelectorAll('.citation-placeholder[data-citation]')
// First pass: collect all citations and assign indices
const citationPlaceholders = Array.from(contentRef.current.querySelectorAll('.citation-placeholder[data-citation]'))
console.log('AsciidocArticle: Found citation placeholders', {
count: citationPlaceholders.length,
placeholders: citationPlaceholders.map(el => ({
id: el.getAttribute('data-citation'),
type: el.getAttribute('data-citation-type')
}))
})
citationsRef.current = []
citationIndexRef.current = 0
citationPlaceholders.forEach((element) => {
const citationId = element.getAttribute('data-citation')
const citationType = element.getAttribute('data-citation-type') || 'end'
if (!citationId) {
logger.warn('Citation placeholder found but no citation ID attribute')
console.warn('Citation placeholder found but no citation ID attribute')
return
}
// Determine container class based on citation type
const isInline = citationType === 'inline' || citationType === 'prompt-inline'
const container = document.createElement(isInline ? 'span' : 'div')
container.className = isInline ? 'inline' : 'w-full my-2'
const citationIndex = citationIndexRef.current++
citationsRef.current.push({
id: `citation-${citationIndex}`,
type: citationType,
citationId,
index: citationIndex
})
})
console.log('AsciidocArticle: Collected citations', {
count: citationsRef.current.length,
citations: citationsRef.current
})
// Second pass: render citations based on type
citationPlaceholders.forEach((element, idx) => {
const citationId = element.getAttribute('data-citation')
const citationType = element.getAttribute('data-citation-type') || 'end'
if (!citationId) return
const citation = citationsRef.current[idx]
if (!citation) return
const citationNumber = citation.index + 1
const parent = element.parentNode
if (!parent) {
logger.warn('Citation placeholder has no parent node')
return
}
// Handle different citation types
if (citationType === 'inline' || citationType === 'prompt-inline') {
// Inline citations render as clickable text
const container = document.createElement('span')
container.className = 'inline'
container.style.display = 'inline'
container.style.whiteSpace = 'nowrap'
parent.replaceChild(container, element)
// Use React to render the component
const root = createRoot(container)
root.render(
<DeletedEventProvider>
<ReplyProvider>
<EmbeddedCitation
citationId={citationId}
displayType={citationType as 'end' | 'foot' | 'foot-end' | 'inline' | 'quote' | 'prompt-end' | 'prompt-inline'}
displayType={citationType as 'inline' | 'prompt-inline'}
/>
</ReplyProvider>
</DeletedEventProvider>
)
reactRootsRef.current.set(container, root)
} else if (citationType === 'foot' || citationType === 'foot-end') {
// Footnotes render as superscript numbers
const sup = document.createElement('sup')
sup.className = 'citation-ref'
sup.style.display = 'inline'
sup.style.whiteSpace = 'nowrap'
const link = document.createElement('a')
link.href = `#citation-${citation.index}`
link.id = `citation-ref-${citation.index}`
link.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline no-underline'
link.textContent = `[${citationNumber}]`
link.addEventListener('click', (e) => {
e.preventDefault()
const citationElement = document.getElementById(`citation-${citation.index}`)
if (citationElement) {
citationElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
sup.appendChild(link)
parent.replaceChild(sup, element)
} else if (citationType === 'end' || citationType === 'prompt-end') {
// Endnotes render as superscript numbers that link to references section
const sup = document.createElement('sup')
sup.className = 'citation-ref'
sup.style.display = 'inline'
sup.style.whiteSpace = 'nowrap'
const link = document.createElement('a')
link.href = '#references-section'
link.id = `citation-ref-${citation.index}`
link.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline no-underline'
link.textContent = `[${citationNumber}]`
link.addEventListener('click', (e) => {
e.preventDefault()
const refSection = document.getElementById('references-section')
if (refSection) {
refSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
})
sup.appendChild(link)
parent.replaceChild(sup, element)
} else if (citationType === 'quote') {
// Quotes render as block-level citation cards
const container = document.createElement('div')
container.className = 'w-full my-2'
parent.replaceChild(container, element)
const root = createRoot(container)
root.render(
<DeletedEventProvider>
<ReplyProvider>
<EmbeddedCitation
citationId={citationId}
displayType="quote"
/>
</ReplyProvider>
</DeletedEventProvider>
)
reactRootsRef.current.set(container, root)
}
})
// Render footnotes and references sections
const footnotes = citationsRef.current.filter(c => c.type === 'foot' || c.type === 'foot-end')
const endCitations = citationsRef.current.filter(c => c.type === 'end' || c.type === 'prompt-end')
console.log('AsciidocArticle: Processing citations', {
totalCitations: citationsRef.current.length,
footnotesCount: footnotes.length,
endCitationsCount: endCitations.length,
allCitations: citationsRef.current
})
if (!contentRef.current?.parentElement) {
console.warn('AsciidocArticle: contentRef parent not found, cannot render footnotes/references')
return
}
const parentContainer = contentRef.current.parentElement
// Check if sections already exist
const existingFootnotes = parentContainer.querySelector('#footnotes-section')
const existingReferences = parentContainer.querySelector('#references-section')
// If sections already exist and we have no new citations, preserve existing sections
// This handles the case where useEffect runs again after placeholders are replaced
if ((existingFootnotes || existingReferences) && citationsRef.current.length === 0) {
console.log('AsciidocArticle: Sections already exist, preserving them', {
hasFootnotes: !!existingFootnotes,
hasReferences: !!existingReferences
})
return
}
// Remove existing sections only if we're going to recreate them with new data
if (existingFootnotes && footnotes.length > 0) {
existingFootnotes.remove()
}
if (existingReferences && endCitations.length > 0) {
existingReferences.remove()
}
console.log('AsciidocArticle: Rendering citation sections', {
footnotesCount: footnotes.length,
endCitationsCount: endCitations.length,
totalCitations: citationsRef.current.length,
parentContainer: parentContainer.tagName,
hasContentRef: !!contentRef.current,
hadExistingFootnotes: !!existingFootnotes,
hadExistingReferences: !!existingReferences
})
// Render footnotes section
if (footnotes.length > 0) {
const footnotesSection = document.createElement('div')
footnotesSection.id = 'footnotes-section'
footnotesSection.className = 'mt-8 pt-4 border-t border-gray-300 dark:border-gray-700'
const h3 = document.createElement('h3')
h3.className = 'text-lg font-semibold mb-4'
h3.textContent = 'Footnotes'
footnotesSection.appendChild(h3)
const ol = document.createElement('ol')
ol.className = 'list-decimal list-inside space-y-2'
footnotes.forEach((citation) => {
const li = document.createElement('li')
li.id = citation.id
li.className = 'text-sm'
const span = document.createElement('span')
span.className = 'font-semibold'
span.textContent = `[${citation.index + 1}]: `
li.appendChild(span)
const citationContainer = document.createElement('span')
citationContainer.className = 'inline-block mt-1'
li.appendChild(citationContainer)
const backLink = document.createElement('a')
backLink.href = `#citation-ref-${citation.index}`
backLink.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs ml-1'
backLink.textContent = '↩'
backLink.addEventListener('click', (e) => {
e.preventDefault()
const refElement = document.getElementById(`citation-ref-${citation.index}`)
if (refElement) {
refElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
li.appendChild(backLink)
ol.appendChild(li)
// Render citation component
const citationRoot = createRoot(citationContainer)
citationRoot.render(
<DeletedEventProvider>
<ReplyProvider>
<EmbeddedCitation
citationId={citation.citationId}
displayType={citation.type === 'foot-end' ? 'foot-end' : 'foot'}
/>
</ReplyProvider>
</DeletedEventProvider>
)
reactRootsRef.current.set(citationContainer, citationRoot)
})
footnotesSection.appendChild(ol)
// Insert after contentRef div - use insertAdjacentElement for more reliable insertion
contentRef.current.insertAdjacentElement('afterend', footnotesSection)
// Verify insertion
const insertedFootnotes = parentContainer.querySelector('#footnotes-section')
console.log('AsciidocArticle: Footnotes section created and inserted', {
footnotesCount: footnotes.length,
parentTagName: parentContainer.tagName,
sectionId: footnotesSection.id,
isInDOM: !!insertedFootnotes,
sectionVisible: insertedFootnotes ? window.getComputedStyle(insertedFootnotes).display !== 'none' : false,
sectionText: insertedFootnotes?.textContent?.substring(0, 100)
})
}
// Render references section
if (endCitations.length > 0) {
const referencesSection = document.createElement('div')
referencesSection.id = 'references-section'
referencesSection.className = 'mt-8 pt-4 border-t border-gray-300 dark:border-gray-700'
const h3 = document.createElement('h3')
h3.className = 'text-lg font-semibold mb-4'
h3.textContent = 'References'
referencesSection.appendChild(h3)
const ol = document.createElement('ol')
ol.className = 'list-decimal list-inside space-y-2'
endCitations.forEach((citation) => {
const li = document.createElement('li')
li.id = `citation-end-${citation.index}`
li.className = 'text-sm'
const span = document.createElement('span')
span.className = 'font-semibold'
span.textContent = `[${citation.index + 1}]: `
li.appendChild(span)
const citationContainer = document.createElement('span')
citationContainer.className = 'inline-block mt-1'
li.appendChild(citationContainer)
const backLink = document.createElement('a')
backLink.href = `#citation-ref-${citation.index}`
backLink.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs ml-1'
backLink.textContent = '↩'
backLink.addEventListener('click', (e) => {
e.preventDefault()
const refElement = document.getElementById(`citation-ref-${citation.index}`)
if (refElement) {
refElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
li.appendChild(backLink)
ol.appendChild(li)
// Render citation component
const citationRoot = createRoot(citationContainer)
citationRoot.render(
<DeletedEventProvider>
<ReplyProvider>
<EmbeddedCitation
citationId={citation.citationId}
displayType={citation.type as 'end' | 'prompt-end'}
/>
</ReplyProvider>
</DeletedEventProvider>
)
reactRootsRef.current.set(citationContainer, citationRoot)
})
referencesSection.appendChild(ol)
// Insert after footnotes section if it exists, otherwise after contentRef
const footnotesSection = parentContainer.querySelector('#footnotes-section')
if (footnotesSection) {
// Insert after footnotes section
footnotesSection.insertAdjacentElement('afterend', referencesSection)
} else {
// No footnotes section, insert after contentRef
contentRef.current.insertAdjacentElement('afterend', referencesSection)
}
// Verify insertion
const insertedReferences = parentContainer.querySelector('#references-section')
console.log('AsciidocArticle: References section created and inserted', {
endCitationsCount: endCitations.length,
hasFootnotesSection: !!footnotesSection,
sectionId: referencesSection.id,
isInDOM: !!insertedReferences,
sectionHTML: insertedReferences?.outerHTML?.substring(0, 200)
})
}
// Process LaTeX math expressions - render with KaTeX
const latexInlinePlaceholders = contentRef.current.querySelectorAll('.latex-inline-placeholder[data-latex-inline]')
latexInlinePlaceholders.forEach((element) => {
@ -1422,6 +1763,21 @@ export default function AsciidocArticle({ @@ -1422,6 +1763,21 @@ export default function AsciidocArticle({
.dark .asciidoc-content a[href^="/notes?t="]:hover {
color: #86efac !important;
}
.asciidoc-content .citation-placeholder.inline,
.asciidoc-content .citation-placeholder.inline > * {
display: inline !important;
}
.asciidoc-content .citation-ref,
.asciidoc-content .citation-ref > * {
display: inline !important;
white-space: nowrap;
}
.asciidoc-content sup.citation-ref {
display: inline !important;
vertical-align: super;
font-size: 0.83em;
line-height: 0;
}
`}</style>
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}>
{/* Metadata */}
@ -1598,6 +1954,10 @@ export default function AsciidocArticle({ @@ -1598,6 +1954,10 @@ export default function AsciidocArticle({
</div>
)}
{/* Footnotes and References sections - rendered via useEffect after citations are processed */}
<div id="footnotes-section-container"></div>
<div id="references-section-container"></div>
</div>
{/* Image gallery lightbox */}

21
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -1034,7 +1034,11 @@ function parseMarkdownContent( @@ -1034,7 +1034,11 @@ function parseMarkdownContent(
)
if (!isInOther && !isWithinBlockPattern(start, end, blockPatterns)) {
const citationType = match[1]
const citationId = match[2]
let citationId = match[2]
// Strip nostr: prefix if present
if (citationId.startsWith('nostr:')) {
citationId = citationId.substring(6) // Remove 'nostr:' prefix
}
const citationIndex = citations.length
citations.push({ id: `citation-${citationIndex}`, type: citationType, citationId })
patterns.push({
@ -1096,12 +1100,25 @@ function parseMarkdownContent( @@ -1096,12 +1100,25 @@ function parseMarkdownContent(
})
// Wikilinks ([[link]] or [[link|display]]) - but not inside markdown links
// Exclude citations ([[citation::...]]) and bookstr links ([[book::...]]) from wikilink processing
const wikilinkRegex = /\[\[([^\]]+)\]\]/g
const wikilinkMatches = Array.from(content.matchAll(wikilinkRegex))
wikilinkMatches.forEach(match => {
if (match.index !== undefined) {
const start = match.index
const end = match.index + match[0].length
const linkContent = match[1]
// Skip citations - they're already processed above
if (linkContent.startsWith('citation::')) {
return
}
// Skip bookstr links - they're handled separately
if (linkContent.startsWith('book::')) {
return
}
// Only add if not already covered by another pattern and not in block pattern
const isInOther = patterns.some(p =>
start >= p.index &&
@ -1112,7 +1129,7 @@ function parseMarkdownContent( @@ -1112,7 +1129,7 @@ function parseMarkdownContent(
index: start,
end: end,
type: 'wikilink',
data: match[1]
data: linkContent
})
}
}

6
src/components/Note/index.tsx

@ -80,7 +80,11 @@ export default function Note({ @@ -80,7 +80,11 @@ export default function Note({
ExtendedKind.ZAP_REQUEST,
ExtendedKind.ZAP_RECEIPT,
ExtendedKind.PUBLICATION_CONTENT, // Only for rendering embedded content, not in feeds
ExtendedKind.FOLLOW_PACK // Only for rendering embedded content, not in feeds
ExtendedKind.FOLLOW_PACK, // Only for rendering embedded content, not in feeds
ExtendedKind.CITATION_INTERNAL, // Citations for rendering
ExtendedKind.CITATION_EXTERNAL,
ExtendedKind.CITATION_HARDCOPY,
ExtendedKind.CITATION_PROMPT
]

Loading…
Cancel
Save