10 changed files with 569 additions and 29 deletions
@ -0,0 +1,224 @@
@@ -0,0 +1,224 @@
|
||||
import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle' |
||||
import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' |
||||
import NoteOptions from '@/components/NoteOptions' |
||||
import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' |
||||
import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler' |
||||
import { fetchPublicationTreeForExport } from '@/lib/publication-export' |
||||
import { |
||||
buildPublicationSectionTree, |
||||
flattenPublicationSectionTreeForToc, |
||||
type PublicationSectionTreeNode |
||||
} from '@/lib/publication-section-tree' |
||||
import { normalizeAnyRelayUrl } from '@/lib/url' |
||||
import { cn } from '@/lib/utils' |
||||
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { BookOpen, Loader2 } from 'lucide-react' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
import { useCallback, useEffect, useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
const ASCIIDOC_CONTENT_KINDS = new Set<number>([ |
||||
ExtendedKind.PUBLICATION_CONTENT, |
||||
ExtendedKind.WIKI_ARTICLE |
||||
]) |
||||
|
||||
type HeadingTag = 'h2' | 'h3' | 'h4' | 'h5' | 'h6' |
||||
|
||||
function SectionHeadingRow({ |
||||
title, |
||||
event, |
||||
Heading |
||||
}: { |
||||
title: string |
||||
event?: Event |
||||
Heading: HeadingTag |
||||
}) { |
||||
return ( |
||||
<div className="flex min-w-0 items-start gap-1"> |
||||
<Heading className="min-w-0 flex-1 text-base font-semibold break-words text-foreground"> |
||||
{title} |
||||
</Heading> |
||||
{event ? <NoteOptions event={event} className="shrink-0 -mr-1 -mt-0.5" /> : null} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
function SectionContent({ event }: { event: Event }) { |
||||
if (ASCIIDOC_CONTENT_KINDS.has(event.kind)) { |
||||
return ( |
||||
<AsciidocArticle className="mt-2" event={event} hideImagesAndInfo hideTitle /> |
||||
) |
||||
} |
||||
if (event.kind === kinds.LongFormArticle) { |
||||
return <MarkdownArticle className="mt-2" event={event} hideMetadata /> |
||||
} |
||||
if ((event.content ?? '').trim()) { |
||||
return ( |
||||
<div className="mt-2 whitespace-pre-wrap break-words text-base text-foreground"> |
||||
{event.content} |
||||
</div> |
||||
) |
||||
} |
||||
return null |
||||
} |
||||
|
||||
function PublicationSectionNodeView({ node }: { node: PublicationSectionTreeNode }) { |
||||
const Heading = `h${Math.min(6, node.depth + 2)}` as HeadingTag |
||||
|
||||
return ( |
||||
<section id={node.sectionId} className="scroll-mt-24 mt-4 first:mt-0"> |
||||
<SectionHeadingRow title={node.title} event={node.event} Heading={Heading} /> |
||||
{node.isPublicationBranch && node.event?.content.trim() ? ( |
||||
<div className="mt-2 whitespace-pre-wrap break-words text-muted-foreground"> |
||||
{node.event.content.trim()} |
||||
</div> |
||||
) : null} |
||||
{node.isPublicationBranch ? ( |
||||
node.children.length > 0 ? ( |
||||
<div className="mt-4 border-l border-border pl-4"> |
||||
{node.children.map((child) => ( |
||||
<PublicationSectionNodeView key={child.path} node={child} /> |
||||
))} |
||||
</div> |
||||
) : null |
||||
) : node.event ? ( |
||||
<SectionContent event={node.event} /> |
||||
) : null} |
||||
</section> |
||||
) |
||||
} |
||||
|
||||
function PublicationTableOfContents({ |
||||
entries, |
||||
className |
||||
}: { |
||||
entries: ReturnType<typeof flattenPublicationSectionTreeForToc> |
||||
className?: string |
||||
}) { |
||||
const { t } = useTranslation() |
||||
|
||||
const scrollToSection = useCallback((id: string) => { |
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) |
||||
}, []) |
||||
|
||||
if (entries.length === 0) return null |
||||
|
||||
return ( |
||||
<nav |
||||
className={cn('rounded-lg border border-border bg-muted/20 p-3', className)} |
||||
aria-label={t('Publication table of contents')} |
||||
> |
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-foreground"> |
||||
<BookOpen className="size-4 shrink-0 text-muted-foreground" aria-hidden /> |
||||
{t('Publication table of contents')} |
||||
</div> |
||||
<ol className="max-h-64 space-y-0.5 overflow-y-auto text-sm"> |
||||
{entries.map((entry) => ( |
||||
<li key={entry.path}> |
||||
<button |
||||
type="button" |
||||
className="w-full min-w-0 rounded py-1 pr-2 text-left text-muted-foreground hover:bg-accent hover:text-accent-foreground" |
||||
style={{ paddingLeft: `${8 + entry.depth * 14}px` }} |
||||
onClick={() => scrollToSection(entry.id)} |
||||
> |
||||
<span className="break-words">{entry.title}</span> |
||||
</button> |
||||
</li> |
||||
))} |
||||
</ol> |
||||
</nav> |
||||
) |
||||
} |
||||
|
||||
export default function PublicationIndexBody({ |
||||
event, |
||||
className |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() |
||||
const { favoriteRelays } = useFavoriteRelays() |
||||
const relayUrls = useMemo( |
||||
() => |
||||
Array.from( |
||||
new Set([ |
||||
...currentBrowsingRelayUrls.map((url) => normalizeAnyRelayUrl(url) || url), |
||||
...favoriteRelays.map((url) => normalizeAnyRelayUrl(url) || url), |
||||
...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url) |
||||
]) |
||||
).filter(Boolean) as string[], |
||||
[currentBrowsingRelayUrls, favoriteRelays] |
||||
) |
||||
|
||||
const [fetched, setFetched] = useState<Map<string, Event> | null>(null) |
||||
const [loading, setLoading] = useState(true) |
||||
const [error, setError] = useState<string | null>(null) |
||||
|
||||
useEffect(() => { |
||||
let cancelled = false |
||||
setLoading(true) |
||||
setError(null) |
||||
setFetched(null) |
||||
|
||||
fetchPublicationTreeForExport(event, relayUrls) |
||||
.then((tree) => { |
||||
if (cancelled) return |
||||
setFetched(tree) |
||||
}) |
||||
.catch((err: unknown) => { |
||||
if (cancelled) return |
||||
setError(err instanceof Error ? err.message : String(err)) |
||||
}) |
||||
.finally(() => { |
||||
if (!cancelled) setLoading(false) |
||||
}) |
||||
|
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
}, [event, relayUrls]) |
||||
|
||||
const sectionTree = useMemo( |
||||
() => (fetched ? buildPublicationSectionTree(event, fetched) : []), |
||||
[event, fetched] |
||||
) |
||||
|
||||
const tocEntries = useMemo( |
||||
() => flattenPublicationSectionTreeForToc(sectionTree), |
||||
[sectionTree] |
||||
) |
||||
|
||||
const hasRefs = orderedPublicationRefsFromIndex(event).length > 0 |
||||
if (!hasRefs) return null |
||||
|
||||
return ( |
||||
<div className={cn('min-w-0 space-y-4', className)}> |
||||
{loading ? ( |
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/20 p-3 text-sm text-muted-foreground"> |
||||
<Loader2 className="size-4 shrink-0 animate-spin" aria-hidden /> |
||||
{t('Publication contents loading')} |
||||
</div> |
||||
) : null} |
||||
|
||||
{error ? ( |
||||
<p className="text-sm text-destructive"> |
||||
{t('Publication contents load failed')}: {error} |
||||
</p> |
||||
) : null} |
||||
|
||||
{fetched && !error ? ( |
||||
<> |
||||
<PublicationTableOfContents entries={tocEntries} /> |
||||
<div> |
||||
{sectionTree.map((node) => ( |
||||
<PublicationSectionNodeView key={node.path} node={node} /> |
||||
))} |
||||
</div> |
||||
</> |
||||
) : null} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { ExtendedKind } from '@/constants' |
||||
import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler' |
||||
import { |
||||
buildPublicationSectionTree, |
||||
flattenPublicationSectionTreeForToc |
||||
} from '@/lib/publication-section-tree' |
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
const PK = 'a'.repeat(64) |
||||
|
||||
function indexEvent(tags: string[][], id: string): Event { |
||||
return { |
||||
id, |
||||
kind: ExtendedKind.PUBLICATION, |
||||
pubkey: PK, |
||||
created_at: 100, |
||||
content: '', |
||||
tags, |
||||
sig: 'c'.repeat(128) |
||||
} |
||||
} |
||||
|
||||
function sectionEvent(d: string, title: string, id: string): Event { |
||||
return { |
||||
id, |
||||
kind: ExtendedKind.PUBLICATION_CONTENT, |
||||
pubkey: PK, |
||||
created_at: 50, |
||||
content: `Body of ${title}`, |
||||
tags: [['d', d], ['title', title]], |
||||
sig: 'd'.repeat(128) |
||||
} |
||||
} |
||||
|
||||
describe('buildPublicationSectionTree', () => { |
||||
it('preserves a-tag order from each 30040 index', () => { |
||||
const c1 = `30041:${PK}:chapter-1` |
||||
const c2 = `30041:${PK}:chapter-2` |
||||
const c3 = `30041:${PK}:chapter-3` |
||||
const root = indexEvent( |
||||
[ |
||||
['d', 'book'], |
||||
['title', 'Book'], |
||||
['a', c3, '', 'Three'], |
||||
['t', 'ignored'], |
||||
['a', c1, '', 'One'], |
||||
['a', c2, '', 'Two'] |
||||
], |
||||
'root-id' |
||||
) |
||||
const ch1 = sectionEvent('chapter-1', 'Chapter One', 'ch1-id') |
||||
const ch2 = sectionEvent('chapter-2', 'Chapter Two', 'ch2-id') |
||||
const ch3 = sectionEvent('chapter-3', 'Chapter Three', 'ch3-id') |
||||
const fetched = new Map<string, Event>([ |
||||
[c1, ch1], |
||||
[c2, ch2], |
||||
[c3, ch3] |
||||
]) |
||||
|
||||
const tree = buildPublicationSectionTree(root, fetched) |
||||
expect(tree.map((n) => n.title)).toEqual(['Chapter Three', 'Chapter One', 'Chapter Two']) |
||||
expect(tree.map((n) => n.tagOrder)).toEqual([0, 1, 2]) |
||||
}) |
||||
|
||||
it('keeps TOC and content traversal in the same order', () => { |
||||
const part = `30040:${PK}:part-1` |
||||
const c1 = `30041:${PK}:chapter-1` |
||||
const c2 = `30041:${PK}:chapter-2` |
||||
const root = indexEvent( |
||||
[ |
||||
['d', 'book'], |
||||
['title', 'Book'], |
||||
['a', part], |
||||
['a', c2], |
||||
['a', c1] |
||||
], |
||||
'root-id' |
||||
) |
||||
const partIndex = indexEvent( |
||||
[ |
||||
['d', 'part-1'], |
||||
['title', 'Part One'], |
||||
['a', c1, '', 'First'], |
||||
['a', c2, '', 'Second'] |
||||
], |
||||
'part-id' |
||||
) |
||||
const ch1 = sectionEvent('chapter-1', 'Chapter One', 'ch1-id') |
||||
const ch2 = sectionEvent('chapter-2', 'Chapter Two', 'ch2-id') |
||||
const fetched = new Map<string, Event>([ |
||||
[part, partIndex], |
||||
[c1, ch1], |
||||
[c2, ch2] |
||||
]) |
||||
|
||||
const tree = buildPublicationSectionTree(root, fetched) |
||||
const toc = flattenPublicationSectionTreeForToc(tree) |
||||
|
||||
expect(toc.map((e) => e.title)).toEqual([ |
||||
'Part One', |
||||
'Chapter One', |
||||
'Chapter Two', |
||||
'Chapter Two', |
||||
'Chapter One' |
||||
]) |
||||
|
||||
const contentOrder = (function walk(nodes: typeof tree): string[] { |
||||
const titles: string[] = [] |
||||
for (const node of nodes) { |
||||
titles.push(node.title) |
||||
if (node.children.length > 0) titles.push(...walk(node.children)) |
||||
} |
||||
return titles |
||||
})(tree) |
||||
|
||||
expect(contentOrder).toEqual(toc.map((e) => e.title)) |
||||
}) |
||||
|
||||
it('orderedPublicationRefsFromIndex assigns tagOrder in tag-list sequence', () => { |
||||
const root = indexEvent( |
||||
[ |
||||
['d', 'book'], |
||||
['title', 'Book'], |
||||
['a', `30041:${PK}:b`], |
||||
['summary', 'x'], |
||||
['a', `30041:${PK}:a`], |
||||
['e', 'f'.repeat(64)] |
||||
], |
||||
'root-id' |
||||
) |
||||
const refs = orderedPublicationRefsFromIndex(root) |
||||
expect(refs.map((r) => r.tagOrder)).toEqual([0, 1, 2]) |
||||
expect(refs.map((r) => r.type)).toEqual(['a', 'a', 'e']) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { eventTagAddress } from '@/lib/publication-index' |
||||
import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler' |
||||
import { |
||||
publicationRefKey, |
||||
type PublicationSectionRef |
||||
} from '@/lib/publication-section-fetch' |
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
const MAX_NEST_DEPTH = 8 |
||||
|
||||
export type PublicationSectionTreeNode = { |
||||
ref: PublicationSectionRef |
||||
event?: Event |
||||
indexEvent: Event |
||||
depth: number |
||||
tagOrder: number |
||||
path: string |
||||
sectionId: string |
||||
title: string |
||||
isPublicationBranch: boolean |
||||
children: PublicationSectionTreeNode[] |
||||
} |
||||
|
||||
export type PublicationTocEntry = { |
||||
id: string |
||||
title: string |
||||
depth: number |
||||
tagOrder: number |
||||
path: string |
||||
} |
||||
|
||||
function tagValue(event: Event, name: string): string | undefined { |
||||
for (const tag of event.tags) { |
||||
if ((tag[0] || '').trim().toLowerCase() !== name) continue |
||||
const value = tag[1]?.trim() |
||||
if (value) return value |
||||
} |
||||
return undefined |
||||
} |
||||
|
||||
function isHex64(value: string): boolean { |
||||
return /^[0-9a-f]{64}$/i.test(value) |
||||
} |
||||
|
||||
function sectionLabelMapFromIndex(index: Event): Map<string, string> { |
||||
const map = new Map<string, string>() |
||||
for (const tag of index.tags) { |
||||
if (tag[0] !== 'a' || !tag[1]) continue |
||||
const label = tag[3]?.trim() |
||||
if (!label || label.startsWith('wss://') || label.startsWith('ws://') || isHex64(label)) continue |
||||
map.set(tag[1], label) |
||||
} |
||||
return map |
||||
} |
||||
|
||||
function coordinateForRef(ref: PublicationSectionRef, event?: Event): string | undefined { |
||||
if (ref.coordinate?.trim()) return ref.coordinate.trim() |
||||
if (event) return eventTagAddress(event) ?? undefined |
||||
return undefined |
||||
} |
||||
|
||||
function humanizeIdentifier(identifier: string): string | undefined { |
||||
if (!identifier || isHex64(identifier)) return undefined |
||||
return identifier.replace(/-/g, ' ') |
||||
} |
||||
|
||||
export function sectionTitle( |
||||
ref: PublicationSectionRef, |
||||
event: Event | undefined, |
||||
labelMap: Map<string, string> |
||||
): string { |
||||
if (event) { |
||||
const title = tagValue(event, 'title') |
||||
if (title) return title |
||||
const dTag = tagValue(event, 'd') |
||||
const humanizedD = dTag ? humanizeIdentifier(dTag) : undefined |
||||
if (humanizedD) return humanizedD |
||||
} |
||||
|
||||
const coordinate = coordinateForRef(ref, event) |
||||
if (coordinate) { |
||||
const label = labelMap.get(coordinate) |
||||
if (label) return label |
||||
} |
||||
|
||||
const identifier = |
||||
ref.identifier ?? |
||||
(coordinate ? coordinate.split(':').slice(2).join(':') : undefined) |
||||
const humanized = identifier ? humanizeIdentifier(identifier) : undefined |
||||
if (humanized) return humanized |
||||
|
||||
return 'Section' |
||||
} |
||||
|
||||
function sectionAnchorId(path: string, refKey: string): string { |
||||
const slug = refKey |
||||
.toLowerCase() |
||||
.replace(/[^a-z0-9]+/g, '-') |
||||
.replace(/^-+|-+$/g, '') |
||||
.slice(0, 80) |
||||
return slug ? `pub-section-${path.replace(/\//g, '-')}-${slug}` : `pub-section-${path.replace(/\//g, '-')}` |
||||
} |
||||
|
||||
function resolveRefEvent( |
||||
ref: PublicationSectionRef, |
||||
fetched: Map<string, Event> |
||||
): Event | undefined { |
||||
return fetched.get(publicationRefKey(ref)) |
||||
} |
||||
|
||||
function isPublicationBranchRef(ref: PublicationSectionRef): boolean { |
||||
if (ref.type !== 'a' || !ref.coordinate) return false |
||||
const kind = ref.kind ?? parseInt(ref.coordinate.split(':')[0], 10) |
||||
return kind === ExtendedKind.PUBLICATION |
||||
} |
||||
|
||||
/** |
||||
* Build a section tree mirroring each index event's `a` / `e` tag order. |
||||
* Sibling order matches `orderedPublicationRefsFromIndex`; children follow depth-first. |
||||
*/ |
||||
export function buildPublicationSectionTree( |
||||
indexEvent: Event, |
||||
fetched: Map<string, Event>, |
||||
depth = 0, |
||||
pathPrefix = '' |
||||
): PublicationSectionTreeNode[] { |
||||
if (depth > MAX_NEST_DEPTH) return [] |
||||
|
||||
const labelMap = sectionLabelMapFromIndex(indexEvent) |
||||
const refs = orderedPublicationRefsFromIndex(indexEvent) |
||||
const nodes: PublicationSectionTreeNode[] = [] |
||||
|
||||
for (const ref of refs) { |
||||
const tagOrder = ref.tagOrder ?? nodes.length |
||||
const event = resolveRefEvent(ref, fetched) |
||||
const coordinate = coordinateForRef(ref, event) |
||||
const refKey = coordinate || publicationRefKey(ref) |
||||
const path = pathPrefix ? `${pathPrefix}/${tagOrder}` : String(tagOrder) |
||||
const isPublicationBranch = isPublicationBranchRef(ref) |
||||
const children = |
||||
isPublicationBranch && event |
||||
? buildPublicationSectionTree(event, fetched, depth + 1, path) |
||||
: [] |
||||
|
||||
nodes.push({ |
||||
ref, |
||||
event, |
||||
indexEvent, |
||||
depth, |
||||
tagOrder, |
||||
path, |
||||
sectionId: sectionAnchorId(path, refKey), |
||||
title: sectionTitle(ref, event, labelMap), |
||||
isPublicationBranch, |
||||
children |
||||
}) |
||||
} |
||||
|
||||
return nodes |
||||
} |
||||
|
||||
/** Depth-first flattening preserves the same order as {@link buildPublicationSectionTree}. */ |
||||
export function flattenPublicationSectionTreeForToc( |
||||
nodes: PublicationSectionTreeNode[] |
||||
): PublicationTocEntry[] { |
||||
const entries: PublicationTocEntry[] = [] |
||||
for (const node of nodes) { |
||||
entries.push({ |
||||
id: node.sectionId, |
||||
title: node.title, |
||||
depth: node.depth, |
||||
tagOrder: node.tagOrder, |
||||
path: node.path |
||||
}) |
||||
if (node.children.length > 0) { |
||||
entries.push(...flattenPublicationSectionTreeForToc(node.children)) |
||||
} |
||||
} |
||||
return entries |
||||
} |
||||
Loading…
Reference in new issue