File
Blob: src/sites/static-renderer/outline.ts
| 1 | import type { JSONContent } from "@tiptap/core"; |
| 2 | import { |
| 3 | createUniqueOutlineAnchorId, |
| 4 | normalizeOutlineText, |
| 5 | readOutlineLevel, |
| 6 | type OutlineItem, |
| 7 | } from "@/shared/editor/model/outline"; |
| 8 | |
| 9 | export interface SitesOutline { |
| 10 | items: OutlineItem[]; |
| 11 | headingAnchorIds: (string | null)[]; |
| 12 | } |
| 13 | |
| 14 | export function collectSitesOutline(content: JSONContent): SitesOutline { |
| 15 | const used = new Set<string>(); |
| 16 | const items: OutlineItem[] = []; |
| 17 | const headingAnchorIds: (string | null)[] = []; |
| 18 | |
| 19 | walk(content, (node, includeInOutline) => { |
| 20 | if (!includeInOutline) { |
| 21 | headingAnchorIds.push(null); |
| 22 | return; |
| 23 | } |
| 24 | |
| 25 | const text = readNodeText(node); |
| 26 | if (!text) { |
| 27 | headingAnchorIds.push(null); |
| 28 | return; |
| 29 | } |
| 30 | |
| 31 | const id = createUniqueOutlineAnchorId(text, used); |
| 32 | headingAnchorIds.push(id); |
| 33 | items.push({ |
| 34 | id, |
| 35 | text, |
| 36 | level: readOutlineLevel(node.attrs?.level), |
| 37 | href: `#${id}`, |
| 38 | }); |
| 39 | }); |
| 40 | |
| 41 | return { items, headingAnchorIds }; |
| 42 | } |
| 43 | |
| 44 | function walk(node: JSONContent, onHeading: (node: JSONContent, includeInOutline: boolean) => void): void { |
| 45 | walkNode(node, onHeading, false); |
| 46 | } |
| 47 | |
| 48 | function walkNode( |
| 49 | node: JSONContent, |
| 50 | onHeading: (node: JSONContent, includeInOutline: boolean) => void, |
| 51 | insideClosedDetails: boolean, |
| 52 | ): void { |
| 53 | if (!node || typeof node !== "object") return; |
| 54 | if (node.type === "heading") { |
| 55 | onHeading(node, !insideClosedDetails); |
| 56 | return; |
| 57 | } |
| 58 | |
| 59 | const nextInsideClosedDetails = insideClosedDetails || (node.type === "details" && !isDetailsOpen(node)); |
| 60 | if (Array.isArray(node.content)) { |
| 61 | for (const child of node.content) { |
| 62 | walkNode(child, onHeading, nextInsideClosedDetails); |
| 63 | } |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | function isDetailsOpen(node: JSONContent): boolean { |
| 68 | return node.attrs?.open === true || node.attrs?.open === "true"; |
| 69 | } |
| 70 | |
| 71 | function readNodeText(node: JSONContent): string { |
| 72 | const parts: string[] = []; |
| 73 | collectText(node, parts); |
| 74 | return normalizeOutlineText(parts.join(" ")); |
| 75 | } |
| 76 | |
| 77 | function collectText(node: JSONContent, parts: string[]): void { |
| 78 | if (typeof node.text === "string") { |
| 79 | parts.push(node.text); |
| 80 | } |
| 81 | if (!Array.isArray(node.content)) return; |
| 82 | for (const child of node.content) { |
| 83 | collectText(child, parts); |
| 84 | } |
| 85 | } |