From 4df079e55237380b750c9c561035956b1b1eecb9 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 4 Dec 2025 19:24:23 +0100 Subject: [PATCH] citation embedding --- CITATION_TEST_CONTENT.adoc | 115 +++++ CITATION_TEST_README.md | 80 ++++ src/components/EmbeddedCitation/index.tsx | 14 +- .../Note/AsciidocArticle/AsciidocArticle.tsx | 406 +++++++++++++++++- .../Note/MarkdownArticle/MarkdownArticle.tsx | 21 +- src/components/Note/index.tsx | 6 +- 6 files changed, 612 insertions(+), 30 deletions(-) create mode 100644 CITATION_TEST_CONTENT.adoc create mode 100644 CITATION_TEST_README.md diff --git a/CITATION_TEST_CONTENT.adoc b/CITATION_TEST_CONTENT.adoc new file mode 100644 index 0000000..e881df1 --- /dev/null +++ b/CITATION_TEST_CONTENT.adoc @@ -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. diff --git a/CITATION_TEST_README.md b/CITATION_TEST_README.md new file mode 100644 index 0000000..8fa739d --- /dev/null +++ b/CITATION_TEST_README.md @@ -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 + diff --git a/src/components/EmbeddedCitation/index.tsx b/src/components/EmbeddedCitation/index.tsx index 049d65a..371ba44 100644 --- a/src/components/EmbeddedCitation/index.tsx +++ b/src/components/EmbeddedCitation/index.tsx @@ -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 || '') diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 57acab6..64404d7 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -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 { // 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({ 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({ 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({ // 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' : ''}">` + }) + // 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 tags (from AsciiDoc link: syntax) @@ -731,13 +768,6 @@ export default function AsciidocArticle({ return `
` }) - // 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, '"').replace(/'/g, ''') - return `
` - }) - // 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({ const reactRootsRef = useRef>(new Map()) // Track which placeholders have been processed to avoid re-processing const processedPlaceholdersRef = useRef>(new Set()) + // Track citations for footnotes and endnotes sections + const citationsRef = useRef>([]) + 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({ }) // 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 } - parent.replaceChild(container, element) - // Use React to render the component - const root = createRoot(container) - root.render( - - ) - reactRootsRef.current.set(container, root) + // 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) + + const root = createRoot(container) + root.render( + + + + + + ) + 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( + + + + + + ) + 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( + + + + + + ) + 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( + + + + + + ) + 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({ .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; + } `}
{/* Metadata */} @@ -1597,6 +1953,10 @@ export default function AsciidocArticle({ ))}
)} + + {/* Footnotes and References sections - rendered via useEffect after citations are processed */} +
+
diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 75cb7d4..e0d2f7b 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -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( }) // 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( index: start, end: end, type: 'wikilink', - data: match[1] + data: linkContent }) } } diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 68ba780..0840fef 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -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 ]