10 changed files with 569 additions and 29 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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