Browse Source

render publication content

imwald
Silberengel 1 week ago
parent
commit
4bfdac6d8c
  1. 11
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  2. 224
      src/components/Note/PublicationIndexBody.tsx
  3. 24
      src/components/Note/PublicationIndexMetadata.tsx
  4. 2
      src/i18n/locales/de.ts
  5. 2
      src/i18n/locales/en.ts
  6. 10
      src/lib/event-metadata.ts
  7. 6
      src/lib/publication-asciidoc-assembler.ts
  8. 2
      src/lib/publication-section-fetch.ts
  9. 136
      src/lib/publication-section-tree.test.ts
  10. 181
      src/lib/publication-section-tree.ts

11
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -350,12 +350,15 @@ export default function AsciidocArticle({ @@ -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({ @@ -1956,8 +1959,8 @@ export default function AsciidocArticle({
`}</style>
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}>
{/* Metadata */}
{!hideImagesAndInfo && metadata.title && <h1 className="break-words">{metadata.title}</h1>}
{!hideImagesAndInfo && !metadata.title && isBookstrEvent && (
{!hideTitle && !hideImagesAndInfo && metadata.title && <h1 className="break-words">{metadata.title}</h1>}
{!hideTitle && !hideImagesAndInfo && !metadata.title && isBookstrEvent && (
<h1 className="break-words">
{bookMetadata.book
? bookMetadata.book
@ -1984,10 +1987,10 @@ export default function AsciidocArticle({ @@ -1984,10 +1987,10 @@ export default function AsciidocArticle({
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
{hideImagesAndInfo && metadata.title && (
{!hideTitle && hideImagesAndInfo && metadata.title && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2>
)}
{hideImagesAndInfo && !metadata.title && isBookstrEvent && (
{!hideTitle && hideImagesAndInfo && !metadata.title && isBookstrEvent && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">
{bookMetadata.book
? bookMetadata.book

224
src/components/Note/PublicationIndexBody.tsx

@ -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>
)
}

24
src/components/Note/PublicationIndexMetadata.tsx

@ -7,13 +7,14 @@ import { toNoteList } from '@/lib/link' @@ -7,13 +7,14 @@ import { toNoteList } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPageOptional } from '@/PageManager'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { BookOpen, ExternalLink } from 'lucide-react'
import { ExternalLink } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import PublicationCoverFallback from './PublicationCoverFallback'
import PublicationCoverImage from './PublicationCoverImage'
import PublicationBooklistButton from './PublicationBooklistButton'
import PublicationIndexBody from './PublicationIndexBody'
function formatAuthorLine(authors: PublicationAuthor[]): string {
if (authors.length === 0) return ''
@ -201,26 +202,7 @@ export default function PublicationIndexMetadata({ @@ -201,26 +202,7 @@ export default function PublicationIndexMetadata({
</div>
) : null}
{isFull && metadata.sections.length > 0 ? (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<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-1 overflow-y-auto text-sm text-muted-foreground">
{metadata.sections.map((section, index) => (
<li key={`${section.coordinate}-${index}`} className="flex min-w-0 gap-2">
<span className="shrink-0 tabular-nums text-muted-foreground/80">{index + 1}.</span>
<span className="min-w-0 break-words">
{section.label ||
section.coordinate.split(':').pop()?.replace(/-/g, ' ') ||
section.coordinate}
</span>
</li>
))}
</ol>
</div>
) : null}
{isFull && metadata.sectionCount > 0 ? <PublicationIndexBody event={event} /> : null}
</div>
)
}

2
src/i18n/locales/de.ts

@ -1671,6 +1671,8 @@ export default { @@ -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.',

2
src/i18n/locales/en.ts

@ -1702,6 +1702,8 @@ export default { @@ -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.',

10
src/lib/event-metadata.ts

@ -717,10 +717,16 @@ export function getPublicationIndexMetadataFromEvent(event: Event): PublicationI @@ -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
})
}
}

6
src/lib/publication-asciidoc-assembler.ts

@ -55,6 +55,7 @@ function authorFromMetadata(metadata: PublicationIndexMetadata, pubkey: string): @@ -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 @@ -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

2
src/lib/publication-section-fetch.ts

@ -15,6 +15,8 @@ export type PublicationSectionRef = { @@ -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 {

136
src/lib/publication-section-tree.test.ts

@ -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'])
})
})

181
src/lib/publication-section-tree.ts

@ -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…
Cancel
Save