diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
index 83df540b..ab847e61 100644
--- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
+++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
@@ -350,12 +350,15 @@ export default function AsciidocArticle({
event,
className,
hideImagesAndInfo = false,
+ hideTitle = false,
parentImageUrl,
footnotesContainerId
}: {
event: Event
className?: string
hideImagesAndInfo?: boolean
+ /** Suppress title headings (e.g. when a parent renders the section title). */
+ hideTitle?: boolean
parentImageUrl?: string
footnotesContainerId?: string
}) {
@@ -1956,8 +1959,8 @@ export default function AsciidocArticle({
`}
{/* Metadata */}
- {!hideImagesAndInfo && metadata.title &&
{metadata.title}
}
- {!hideImagesAndInfo && !metadata.title && isBookstrEvent && (
+ {!hideTitle && !hideImagesAndInfo && metadata.title &&
{metadata.title}
}
+ {!hideTitle && !hideImagesAndInfo && !metadata.title && isBookstrEvent && (
{bookMetadata.book
? bookMetadata.book
@@ -1984,10 +1987,10 @@ export default function AsciidocArticle({
{metadata.summary}
)}
- {hideImagesAndInfo && metadata.title && (
+ {!hideTitle && hideImagesAndInfo && metadata.title && (
{metadata.title}
)}
- {hideImagesAndInfo && !metadata.title && isBookstrEvent && (
+ {!hideTitle && hideImagesAndInfo && !metadata.title && isBookstrEvent && (
{bookMetadata.book
? bookMetadata.book
diff --git a/src/components/Note/PublicationIndexBody.tsx b/src/components/Note/PublicationIndexBody.tsx
new file mode 100644
index 00000000..87c5b0a7
--- /dev/null
+++ b/src/components/Note/PublicationIndexBody.tsx
@@ -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([
+ 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 (
+
+
+ {title}
+
+ {event ? : null}
+
+ )
+}
+
+function SectionContent({ event }: { event: Event }) {
+ if (ASCIIDOC_CONTENT_KINDS.has(event.kind)) {
+ return (
+
+ )
+ }
+ if (event.kind === kinds.LongFormArticle) {
+ return
+ }
+ if ((event.content ?? '').trim()) {
+ return (
+
+ {event.content}
+
+ )
+ }
+ return null
+}
+
+function PublicationSectionNodeView({ node }: { node: PublicationSectionTreeNode }) {
+ const Heading = `h${Math.min(6, node.depth + 2)}` as HeadingTag
+
+ return (
+
+
+ {node.isPublicationBranch && node.event?.content.trim() ? (
+
+ {node.event.content.trim()}
+
+ ) : null}
+ {node.isPublicationBranch ? (
+ node.children.length > 0 ? (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ ) : null
+ ) : node.event ? (
+
+ ) : null}
+
+ )
+}
+
+function PublicationTableOfContents({
+ entries,
+ className
+}: {
+ entries: ReturnType
+ 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 (
+
+ )
+}
+
+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
) : null}
- {isFull && metadata.sections.length > 0 ? (
-
-
-
- {t('Publication table of contents')}
-
-
- {metadata.sections.map((section, index) => (
- -
- {index + 1}.
-
- {section.label ||
- section.coordinate.split(':').pop()?.replace(/-/g, ' ') ||
- section.coordinate}
-
-
- ))}
-
-
- ) : null}
+ {isFull && metadata.sectionCount > 0 ? : null}
)
}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index c4c70a7b..07e2bf5b 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -1671,6 +1671,8 @@ export default {
'Publication sections_other': '{{count}} Abschnitte',
'Publication released': 'Veröffentlicht {{date}}',
'Publication table of contents': 'Inhalt',
+ 'Publication contents loading': 'Inhalt wird geladen…',
+ 'Publication contents load failed': 'Inhalt konnte nicht geladen werden',
'libraryIndexCache.sectionTitle': 'Bibliotheks-Publikationsindex',
'libraryIndexCache.sectionBlurb':
'Zwischengespeicherte Kind-30040-Index-Events für den Bibliotheks-Tab. Beim Leeren wird nur der Entdeckungslisten-Cache entfernt — geöffnete Publikationen bleiben im Lese-Cache.',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 11409506..3cac94e1 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -1702,6 +1702,8 @@ export default {
'Publication sections_other': '{{count}} sections',
'Publication released': 'Released {{date}}',
'Publication table of contents': 'Contents',
+ 'Publication contents loading': 'Loading contents…',
+ 'Publication contents load failed': 'Could not load publication contents',
'libraryIndexCache.sectionTitle': 'Library publication index',
'libraryIndexCache.sectionBlurb':
'Cached kind-30040 index events used to populate the Library tab. Clearing this only removes the discovery list cache—not publications you have opened for reading.',
diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts
index dc75c393..1a034f55 100644
--- a/src/lib/event-metadata.ts
+++ b/src/lib/event-metadata.ts
@@ -717,10 +717,16 @@ export function getPublicationIndexMetadataFromEvent(event: Event): PublicationI
} else if (name === 'l' && !language) {
language = value
} else if (name === 'a') {
- const label = tag[3]?.trim() || tag[2]?.trim()
+ const label = tag[3]?.trim()
sections.push({
coordinate: value,
- label: label && !label.startsWith('wss://') && !label.startsWith('ws://') ? label : undefined
+ label:
+ label &&
+ !label.startsWith('wss://') &&
+ !label.startsWith('ws://') &&
+ !/^[0-9a-f]{64}$/i.test(label)
+ ? label
+ : undefined
})
}
}
diff --git a/src/lib/publication-asciidoc-assembler.ts b/src/lib/publication-asciidoc-assembler.ts
index 084505ee..f6651fd4 100644
--- a/src/lib/publication-asciidoc-assembler.ts
+++ b/src/lib/publication-asciidoc-assembler.ts
@@ -55,6 +55,7 @@ function authorFromMetadata(metadata: PublicationIndexMetadata, pubkey: string):
/** Ordered `a` / `e` refs from index tags (NKBIP-01 section order). */
export function orderedPublicationRefsFromIndex(event: Event): PublicationSectionRef[] {
const refs: PublicationSectionRef[] = []
+ let tagOrder = 0
for (const tag of event.tags) {
const name = (tag[0] || '').trim().toLowerCase()
if (name === 'a' && tag[1]) {
@@ -66,10 +67,11 @@ export function orderedPublicationRefsFromIndex(event: Event): PublicationSectio
kind: parsed.kind,
pubkey: parsed.pubkey,
identifier: parsed.identifier,
- relay: tag[2]
+ relay: tag[2],
+ tagOrder: tagOrder++
})
} else if (name === 'e' && tag[1]) {
- refs.push({ type: 'e', eventId: tag[1], relay: tag[2] })
+ refs.push({ type: 'e', eventId: tag[1], relay: tag[2], tagOrder: tagOrder++ })
}
}
return refs
diff --git a/src/lib/publication-section-fetch.ts b/src/lib/publication-section-fetch.ts
index 11e9d8cc..4e8d18f5 100644
--- a/src/lib/publication-section-fetch.ts
+++ b/src/lib/publication-section-fetch.ts
@@ -15,6 +15,8 @@ export type PublicationSectionRef = {
pubkey?: string
identifier?: string
relay?: string
+ /** Zero-based order among `a` / `e` refs in the index event tag list. */
+ tagOrder?: number
}
export function publicationRefKey(ref: PublicationSectionRef): string {
diff --git a/src/lib/publication-section-tree.test.ts b/src/lib/publication-section-tree.test.ts
new file mode 100644
index 00000000..6ef1ecc4
--- /dev/null
+++ b/src/lib/publication-section-tree.test.ts
@@ -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([
+ [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([
+ [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'])
+ })
+})
diff --git a/src/lib/publication-section-tree.ts b/src/lib/publication-section-tree.ts
new file mode 100644
index 00000000..17cb6403
--- /dev/null
+++ b/src/lib/publication-section-tree.ts
@@ -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 {
+ const map = new Map()
+ 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 {
+ 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
+): 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,
+ 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
+}