@raystack/chronicle 0.1.0-canary.5a2be79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/bin/chronicle.js +2 -0
  2. package/dist/cli/index.js +9980 -0
  3. package/next.config.mjs +10 -0
  4. package/package.json +63 -0
  5. package/source.config.ts +50 -0
  6. package/src/app/[[...slug]]/layout.tsx +15 -0
  7. package/src/app/[[...slug]]/page.tsx +57 -0
  8. package/src/app/api/apis-proxy/route.ts +59 -0
  9. package/src/app/api/health/route.ts +3 -0
  10. package/src/app/api/search/route.ts +90 -0
  11. package/src/app/apis/[[...slug]]/layout.module.css +22 -0
  12. package/src/app/apis/[[...slug]]/layout.tsx +26 -0
  13. package/src/app/apis/[[...slug]]/page.tsx +57 -0
  14. package/src/app/layout.tsx +26 -0
  15. package/src/app/llms-full.txt/route.ts +18 -0
  16. package/src/app/llms.txt/route.ts +15 -0
  17. package/src/app/providers.tsx +8 -0
  18. package/src/cli/commands/build.ts +32 -0
  19. package/src/cli/commands/dev.ts +33 -0
  20. package/src/cli/commands/init.ts +155 -0
  21. package/src/cli/commands/serve.ts +53 -0
  22. package/src/cli/commands/start.ts +33 -0
  23. package/src/cli/index.ts +21 -0
  24. package/src/cli/utils/config.ts +43 -0
  25. package/src/cli/utils/index.ts +3 -0
  26. package/src/cli/utils/process.ts +7 -0
  27. package/src/cli/utils/resolve.ts +4 -0
  28. package/src/cli/utils/scaffold.ts +131 -0
  29. package/src/components/api/code-snippets.module.css +7 -0
  30. package/src/components/api/code-snippets.tsx +76 -0
  31. package/src/components/api/endpoint-page.module.css +58 -0
  32. package/src/components/api/endpoint-page.tsx +283 -0
  33. package/src/components/api/field-row.module.css +126 -0
  34. package/src/components/api/field-row.tsx +204 -0
  35. package/src/components/api/field-section.module.css +24 -0
  36. package/src/components/api/field-section.tsx +100 -0
  37. package/src/components/api/index.ts +8 -0
  38. package/src/components/api/json-editor.module.css +9 -0
  39. package/src/components/api/json-editor.tsx +61 -0
  40. package/src/components/api/key-value-editor.module.css +13 -0
  41. package/src/components/api/key-value-editor.tsx +62 -0
  42. package/src/components/api/method-badge.module.css +4 -0
  43. package/src/components/api/method-badge.tsx +29 -0
  44. package/src/components/api/response-panel.module.css +8 -0
  45. package/src/components/api/response-panel.tsx +44 -0
  46. package/src/components/common/breadcrumb.tsx +3 -0
  47. package/src/components/common/button.tsx +3 -0
  48. package/src/components/common/callout.module.css +7 -0
  49. package/src/components/common/callout.tsx +27 -0
  50. package/src/components/common/code-block.tsx +3 -0
  51. package/src/components/common/dialog.tsx +3 -0
  52. package/src/components/common/index.ts +10 -0
  53. package/src/components/common/input-field.tsx +3 -0
  54. package/src/components/common/sidebar.tsx +3 -0
  55. package/src/components/common/switch.tsx +3 -0
  56. package/src/components/common/table.tsx +3 -0
  57. package/src/components/common/tabs.tsx +3 -0
  58. package/src/components/mdx/code.module.css +42 -0
  59. package/src/components/mdx/code.tsx +27 -0
  60. package/src/components/mdx/details.module.css +37 -0
  61. package/src/components/mdx/details.tsx +18 -0
  62. package/src/components/mdx/image.tsx +38 -0
  63. package/src/components/mdx/index.tsx +35 -0
  64. package/src/components/mdx/link.tsx +38 -0
  65. package/src/components/mdx/mermaid.module.css +9 -0
  66. package/src/components/mdx/mermaid.tsx +37 -0
  67. package/src/components/mdx/paragraph.module.css +8 -0
  68. package/src/components/mdx/paragraph.tsx +19 -0
  69. package/src/components/mdx/table.tsx +40 -0
  70. package/src/components/ui/breadcrumbs.tsx +72 -0
  71. package/src/components/ui/client-theme-switcher.tsx +18 -0
  72. package/src/components/ui/footer.module.css +27 -0
  73. package/src/components/ui/footer.tsx +30 -0
  74. package/src/components/ui/search.module.css +104 -0
  75. package/src/components/ui/search.tsx +202 -0
  76. package/src/lib/api-routes.ts +120 -0
  77. package/src/lib/config.ts +55 -0
  78. package/src/lib/get-llm-text.ts +10 -0
  79. package/src/lib/index.ts +2 -0
  80. package/src/lib/openapi.ts +188 -0
  81. package/src/lib/remark-unused-directives.ts +30 -0
  82. package/src/lib/schema.ts +99 -0
  83. package/src/lib/snippet-generators.ts +87 -0
  84. package/src/lib/source.ts +67 -0
  85. package/src/themes/default/Layout.module.css +81 -0
  86. package/src/themes/default/Layout.tsx +133 -0
  87. package/src/themes/default/Page.module.css +46 -0
  88. package/src/themes/default/Page.tsx +21 -0
  89. package/src/themes/default/Toc.module.css +48 -0
  90. package/src/themes/default/Toc.tsx +66 -0
  91. package/src/themes/default/font.ts +6 -0
  92. package/src/themes/default/index.ts +13 -0
  93. package/src/themes/paper/ChapterNav.module.css +71 -0
  94. package/src/themes/paper/ChapterNav.tsx +96 -0
  95. package/src/themes/paper/Layout.module.css +33 -0
  96. package/src/themes/paper/Layout.tsx +25 -0
  97. package/src/themes/paper/Page.module.css +174 -0
  98. package/src/themes/paper/Page.tsx +107 -0
  99. package/src/themes/paper/ReadingProgress.module.css +132 -0
  100. package/src/themes/paper/ReadingProgress.tsx +294 -0
  101. package/src/themes/paper/index.ts +8 -0
  102. package/src/themes/registry.ts +14 -0
  103. package/src/types/config.ts +69 -0
  104. package/src/types/content.ts +35 -0
  105. package/src/types/index.ts +3 -0
  106. package/src/types/theme.ts +22 -0
  107. package/tsconfig.json +30 -0
@@ -0,0 +1,174 @@
1
+ .main {
2
+ --paper-navbar-height: 40px;
3
+ --paper-navbar-padding: var(--rs-space-3);
4
+ --paper-navbar-total: calc(var(--paper-navbar-height) + var(--paper-navbar-padding) * 2 + 1px);
5
+
6
+ flex: 1;
7
+ max-width: 1024px;
8
+ margin: 0 auto;
9
+ }
10
+
11
+ .navbar {
12
+ height: var(--paper-navbar-height);
13
+ padding: var(--paper-navbar-padding) 0;
14
+ border-bottom: 1px solid var(--rs-color-border-base-primary);
15
+ justify-content: space-between;
16
+ width: 100%;
17
+ position: fixed;
18
+ top: 0;
19
+ background: var(--rs-color-background-neutral-primary);
20
+ z-index: 10;
21
+ max-width: 1024px;
22
+ }
23
+
24
+ .navLeft {
25
+ align-items: center;
26
+ }
27
+
28
+ .navRight {
29
+ align-items: center;
30
+ }
31
+
32
+ .arrow {
33
+ display: flex;
34
+ align-items: center;
35
+ color: var(--rs-color-foreground-base-primary);
36
+ text-decoration: none;
37
+ }
38
+
39
+ .arrow:hover {
40
+ color: var(--rs-color-foreground-accent-primary);
41
+ }
42
+
43
+ .arrowDisabled {
44
+ display: flex;
45
+ align-items: center;
46
+ color: var(--rs-color-foreground-base-tertiary);
47
+ opacity: 0.4;
48
+ cursor: default;
49
+ border: none;
50
+ background: none;
51
+ padding: 0;
52
+ }
53
+
54
+ .breadcrumb {
55
+ font-family: 'SF Mono', 'Fira Code', monospace;
56
+ font-size: var(--rs-font-size-small);
57
+ text-transform: uppercase;
58
+ letter-spacing: 0.05em;
59
+ margin-left: var(--rs-space-3);
60
+ }
61
+
62
+ .separator {
63
+ margin: 0 var(--rs-space-2);
64
+ color: var(--rs-color-foreground-base-tertiary);
65
+ }
66
+
67
+ .crumbLink {
68
+ color: var(--rs-color-foreground-base-tertiary);
69
+ text-decoration: none;
70
+ }
71
+
72
+ .crumbLink:hover {
73
+ color: var(--rs-color-foreground-base-primary);
74
+ }
75
+
76
+ .crumbActive {
77
+ color: var(--rs-color-foreground-base-primary);
78
+ font-weight: 600;
79
+ }
80
+
81
+ .article {
82
+ flex: 1;
83
+ min-width: 0;
84
+ margin-top: var(--paper-navbar-total);
85
+ padding: 0 var(--rs-space-7);
86
+ }
87
+
88
+ .searchButton {
89
+ height: 28px;
90
+ padding: 0 var(--rs-space-3);
91
+ font-size: var(--rs-font-size-small);
92
+ border: none;
93
+ box-shadow: none;
94
+ }
95
+
96
+ .content {
97
+ font-family: Georgia, 'Times New Roman', serif;
98
+ line-height: 1.8;
99
+ background: var(--rs-color-background-base-primary);
100
+ padding: var(--rs-space-9);
101
+ border-left: 1px solid var(--rs-color-border-base-primary);
102
+ border-right: 1px solid var(--rs-color-border-base-primary);
103
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04);
104
+ margin-bottom: var(--rs-space-9);
105
+ }
106
+
107
+ .content h1,
108
+ .content h2,
109
+ .content h3,
110
+ .content h4,
111
+ .content h5,
112
+ .content h6 {
113
+ line-height: 1.4;
114
+ }
115
+
116
+ .content h1 {
117
+ margin: 2rem 0 1rem;
118
+ font-size: 2rem;
119
+ }
120
+
121
+ .content h2 {
122
+ margin: 1.75rem 0 0.75rem;
123
+ font-size: 1.5rem;
124
+ }
125
+
126
+ .content h3 {
127
+ margin: 1.5rem 0 0.5rem;
128
+ font-size: 1.25rem;
129
+ }
130
+
131
+ .content h4 {
132
+ margin: 1.25rem 0 0.5rem;
133
+ font-size: 1.1rem;
134
+ }
135
+
136
+ .content h5 {
137
+ margin: 1rem 0 0.5rem;
138
+ font-size: 1rem;
139
+ }
140
+
141
+ .content h6 {
142
+ margin: 1rem 0 0.5rem;
143
+ font-size: 0.875rem;
144
+ }
145
+
146
+ .content p {
147
+ margin: 0.75rem 0;
148
+ }
149
+
150
+ .content ul,
151
+ .content ol {
152
+ margin: 0.75rem 0;
153
+ padding-left: 1.5rem;
154
+ margin-bottom: var(--rs-space-5);
155
+ }
156
+
157
+ .content li {
158
+ font-size: var(--rs-font-size-regular);
159
+ margin: var(--rs-space-2) 0;
160
+ }
161
+
162
+ .content table {
163
+ margin-bottom: var(--rs-space-5);
164
+ }
165
+
166
+ .content [role="tablist"] {
167
+ margin-bottom: var(--rs-space-3);
168
+ }
169
+
170
+ .content blockquote {
171
+ margin: 1rem 0;
172
+ padding-left: 1rem;
173
+ border-left: 3px solid var(--rs-color-border-base-primary);
174
+ }
@@ -0,0 +1,107 @@
1
+ 'use client'
2
+
3
+ import { useMemo } from 'react'
4
+ import { usePathname } from 'next/navigation'
5
+ import NextLink from 'next/link'
6
+ import { Flex } from '@raystack/apsara'
7
+ import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'
8
+ import type { ThemePageProps, PageTreeItem } from '@/types'
9
+ import { Search } from '@/components/ui/search'
10
+ import { ReadingProgress } from './ReadingProgress'
11
+ import styles from './Page.module.css'
12
+
13
+ function flattenTree(items: PageTreeItem[]): PageTreeItem[] {
14
+ const result: PageTreeItem[] = []
15
+ for (const item of items) {
16
+ if (item.type === 'page' && item.url) result.push(item)
17
+ if (item.children) result.push(...flattenTree(item.children))
18
+ }
19
+ return result
20
+ }
21
+
22
+ function findBreadcrumb(items: PageTreeItem[], slug: string[]): { label: string; href: string }[] {
23
+ const result: { label: string; href: string }[] = []
24
+ for (let i = 0; i < slug.length; i++) {
25
+ const path = '/' + slug.slice(0, i + 1).join('/')
26
+ const found = findInTree(items, path)
27
+ result.push({ label: found?.name ?? slug[i], href: path })
28
+ }
29
+ return result
30
+ }
31
+
32
+ function findInTree(items: PageTreeItem[], path: string): PageTreeItem | undefined {
33
+ for (const item of items) {
34
+ if (item.url === path) return item
35
+ if (item.children) {
36
+ const found = findInTree(item.children, path)
37
+ if (found) return found
38
+ }
39
+ }
40
+ return undefined
41
+ }
42
+
43
+ export function Page({ page, config, tree }: ThemePageProps) {
44
+ const pathname = usePathname()
45
+
46
+ const { prev, next, crumbs } = useMemo(() => {
47
+ const pages = flattenTree(tree.children)
48
+ const currentIndex = pages.findIndex((p) => p.url === pathname)
49
+ return {
50
+ prev: currentIndex > 0 ? pages[currentIndex - 1] : null,
51
+ next: currentIndex < pages.length - 1 ? pages[currentIndex + 1] : null,
52
+ crumbs: findBreadcrumb(tree.children, page.slug),
53
+ }
54
+ }, [tree, pathname, page.slug])
55
+
56
+ return (
57
+ <>
58
+ <main className={styles.main}>
59
+ <Flex align="center" className={styles.navbar}>
60
+ <Flex align="center" gap="small" className={styles.navLeft}>
61
+ {prev ? (
62
+ <NextLink href={prev.url!} className={styles.arrow} aria-label="Previous page">
63
+ <ChevronLeftIcon width={14} height={14} />
64
+ </NextLink>
65
+ ) : (
66
+ <button disabled className={styles.arrowDisabled} aria-label="Previous page">
67
+ <ChevronLeftIcon width={14} height={14} />
68
+ </button>
69
+ )}
70
+ {next ? (
71
+ <NextLink href={next.url!} className={styles.arrow} aria-label="Next page">
72
+ <ChevronRightIcon width={14} height={14} />
73
+ </NextLink>
74
+ ) : (
75
+ <button disabled className={styles.arrowDisabled} aria-label="Next page">
76
+ <ChevronRightIcon width={14} height={14} />
77
+ </button>
78
+ )}
79
+ <nav className={styles.breadcrumb}>
80
+ {crumbs.map((crumb, i) => (
81
+ <span key={crumb.href}>
82
+ {i > 0 && <span className={styles.separator}>/</span>}
83
+ {i === crumbs.length - 1 ? (
84
+ <span className={styles.crumbActive}>{crumb.label}</span>
85
+ ) : (
86
+ <NextLink href={crumb.href} className={styles.crumbLink}>
87
+ {crumb.label}
88
+ </NextLink>
89
+ )}
90
+ </span>
91
+ ))}
92
+ </nav>
93
+ </Flex>
94
+ <Flex align="center" className={styles.navRight}>
95
+ {config.search?.enabled && <Search className={styles.searchButton} />}
96
+ </Flex>
97
+ </Flex>
98
+ <article className={styles.article} data-article-content>
99
+ <div className={styles.content}>
100
+ {page.content}
101
+ </div>
102
+ </article>
103
+ </main>
104
+ <ReadingProgress items={page.toc} />
105
+ </>
106
+ )
107
+ }
@@ -0,0 +1,132 @@
1
+ .container {
2
+ position: fixed;
3
+ right: var(--rs-space-4);
4
+ top: 100px;
5
+ width: 200px;
6
+ height: calc(100vh - 200px);
7
+ z-index: 10;
8
+ }
9
+
10
+ .inner {
11
+ position: absolute;
12
+ inset: 0;
13
+ }
14
+
15
+ .tickContainer {
16
+ position: absolute;
17
+ right: 0;
18
+ display: grid;
19
+ width: 6rem;
20
+ align-items: center;
21
+ justify-content: flex-end;
22
+ }
23
+
24
+ .tickLine {
25
+ height: 1px;
26
+ width: 0.5rem;
27
+ transition: all 100ms;
28
+ }
29
+
30
+ .tickLineBefore {
31
+ background-color: var(--rs-color-foreground-accent-primary);
32
+ }
33
+
34
+ .tickLineAfter {
35
+ background-color: var(--rs-color-background-neutral-tertiary);
36
+ }
37
+
38
+ .tickContainer:hover .tickLine {
39
+ width: 1rem;
40
+ }
41
+
42
+ .tickClickable {
43
+ position: absolute;
44
+ inset-inline: 0;
45
+ height: 0.5rem;
46
+ cursor: pointer;
47
+ }
48
+
49
+ .headingContainer {
50
+ transition: transform 150ms;
51
+ }
52
+
53
+ .headingContainer:hover {
54
+ transform: translateX(-0.125rem);
55
+ }
56
+
57
+ .headingLabel {
58
+ position: absolute;
59
+ display: flex;
60
+ height: 0.75rem;
61
+ align-items: center;
62
+ font-family: var(--rs-font-mono);
63
+ font-size: 0.75rem;
64
+ color: var(--rs-color-foreground-base-primary);
65
+ text-transform: capitalize;
66
+ opacity: 0;
67
+ transition: opacity 300ms;
68
+ }
69
+
70
+ .headingLink {
71
+ color: var(--rs-color-foreground-base-primary);
72
+ text-align: right;
73
+ font-family: var(--rs-font-mono);
74
+ font-size: var(--rs-font-size-mini);
75
+ font-weight: var(--rs-font-weight-regular);
76
+ line-height: var(--rs-line-height-mini);
77
+ letter-spacing: var(--rs-letter-spacing-mini);
78
+ text-decoration: none;
79
+ cursor: pointer;
80
+ }
81
+
82
+ .headingLink:hover {
83
+ color: var(--rs-color-foreground-accent-primary);
84
+ }
85
+
86
+ .container:hover .headingLabel {
87
+ opacity: 1;
88
+ }
89
+
90
+ .connectingLine {
91
+ position: absolute;
92
+ right: 0;
93
+ height: 1px;
94
+ background-color: var(--rs-color-background-neutral-emphasis);
95
+ }
96
+
97
+ .scrollMarkerContainer {
98
+ position: absolute;
99
+ right: 0;
100
+ z-index: 20;
101
+ transition: opacity 300ms;
102
+ pointer-events: none;
103
+ }
104
+
105
+ .scrollMarkerContainerReady {
106
+ opacity: 1;
107
+ }
108
+
109
+ .scrollMarkerContainerNotReady {
110
+ opacity: 0;
111
+ }
112
+
113
+ .scrollMarkerLine {
114
+ height: 1px;
115
+ width: 1rem;
116
+ background-color: var(--rs-color-foreground-accent-primary);
117
+ }
118
+
119
+ .scrollMarkerText {
120
+ position: absolute;
121
+ top: 0;
122
+ left: -2.5rem;
123
+ transform: translateY(-50%);
124
+ font-family: var(--rs-font-mono);
125
+ font-size: 0.75rem;
126
+ color: var(--rs-color-foreground-accent-primary);
127
+ transition: opacity 300ms;
128
+ }
129
+
130
+ .container:hover .scrollMarkerText {
131
+ opacity: 0;
132
+ }
@@ -0,0 +1,294 @@
1
+ 'use client'
2
+
3
+ import { cx } from 'class-variance-authority'
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
5
+ import type { TocItem } from '@/types'
6
+ import styles from './ReadingProgress.module.css'
7
+
8
+ interface Heading {
9
+ title: string
10
+ level: number
11
+ id: string
12
+ url: string
13
+ yPosition: number
14
+ }
15
+
16
+ const ARTICLE_SELECTOR = '[data-article-content]'
17
+ const TICK_HEIGHT = 20
18
+ const NAV_HEIGHT = 60
19
+
20
+ function calculateTickBounds(containerHeight: number) {
21
+ const numTicks = Math.floor(containerHeight / TICK_HEIGHT) + 1
22
+ const maxPosition = (numTicks - 1) * TICK_HEIGHT
23
+ return { numTicks, maxPosition }
24
+ }
25
+
26
+ function snapToTick(value: number, maxPosition: number): number {
27
+ const snapped = Math.round(value / TICK_HEIGHT) * TICK_HEIGHT
28
+ return Math.max(0, Math.min(snapped, maxPosition))
29
+ }
30
+
31
+ function resolveOverlaps(headings: Heading[], maxPosition: number): Heading[] {
32
+ if (headings.length <= 1) return headings
33
+
34
+ const resolved: Heading[] = []
35
+ let lastUsedPos = -TICK_HEIGHT
36
+
37
+ for (const heading of headings) {
38
+ let newPos = heading.yPosition
39
+ if (newPos <= lastUsedPos) {
40
+ newPos = lastUsedPos + TICK_HEIGHT
41
+ }
42
+ resolved.push({ ...heading, yPosition: newPos })
43
+ lastUsedPos = newPos
44
+ }
45
+
46
+ // Backward pass: clamp-and-shift to prevent overlapping positions
47
+ for (let i = resolved.length - 1; i >= 0; i--) {
48
+ const maxAllowed =
49
+ i === resolved.length - 1
50
+ ? maxPosition
51
+ : resolved[i + 1].yPosition - TICK_HEIGHT
52
+
53
+ const clampedPos = Math.max(0, maxAllowed)
54
+ if (resolved[i].yPosition > clampedPos) {
55
+ resolved[i] = { ...resolved[i], yPosition: clampedPos }
56
+ for (let j = i - 1; j >= 0; j--) {
57
+ const upperBound = resolved[j + 1].yPosition - TICK_HEIGHT
58
+ if (resolved[j].yPosition > upperBound) {
59
+ resolved[j] = { ...resolved[j], yPosition: Math.max(0, upperBound) }
60
+ } else {
61
+ break
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ return resolved
68
+ }
69
+
70
+ interface ReadingProgressProps {
71
+ items: TocItem[]
72
+ }
73
+
74
+ export function ReadingProgress({ items }: ReadingProgressProps) {
75
+ const [headings, setHeadings] = useState<Heading[]>([])
76
+ const [containerHeight, setContainerHeight] = useState<number>(0)
77
+ const [ready, setReady] = useState<boolean>(false)
78
+ const [isScrollable, setIsScrollable] = useState<boolean>(true)
79
+ const containerRef = useRef<HTMLDivElement>(null)
80
+ const scrollMarkerRef = useRef<HTMLDivElement>(null)
81
+ const scrollPosRef = useRef<number>(0)
82
+
83
+ const { numTicks, maxPosition } = useMemo(
84
+ () => calculateTickBounds(containerHeight),
85
+ [containerHeight]
86
+ )
87
+
88
+ const recalcHeadings = useCallback(() => {
89
+ const article = document.querySelector(ARTICLE_SELECTOR)
90
+ const container = containerRef.current
91
+ if (!article || !container || !items.length) return
92
+
93
+ const articleBox = article.getBoundingClientRect()
94
+ const containerBox = container.getBoundingClientRect()
95
+ const articleTop = articleBox.top + window.scrollY
96
+
97
+ const hasScroll = articleBox.height > window.innerHeight
98
+ setIsScrollable(hasScroll)
99
+ setContainerHeight(containerBox.height)
100
+
101
+ const { maxPosition: maxPos } = calculateTickBounds(containerBox.height)
102
+
103
+ const mapped = items
104
+ .map((tocItem) => {
105
+ const id = tocItem.url.startsWith('#') ? tocItem.url.slice(1) : tocItem.url
106
+ const node = document.getElementById(id)
107
+ if (!node) return null
108
+
109
+ const { top } = node.getBoundingClientRect()
110
+ const headingPosInArticle = top + window.scrollY - articleTop
111
+ const progress = headingPosInArticle / articleBox.height
112
+ const rawY = progress * maxPos
113
+ const yPos = snapToTick(rawY, maxPos)
114
+
115
+ return {
116
+ title: tocItem.title,
117
+ level: tocItem.depth,
118
+ id,
119
+ url: tocItem.url,
120
+ yPosition: yPos,
121
+ }
122
+ })
123
+ .filter((item): item is Heading => item !== null)
124
+
125
+ const resolvedItems = resolveOverlaps(mapped, maxPos)
126
+ setHeadings(resolvedItems)
127
+ }, [items])
128
+
129
+ // Imperative DOM updates to avoid React re-render on every scroll event
130
+ const handleScroll = useCallback(() => {
131
+ const article = document.querySelector(ARTICLE_SELECTOR)
132
+ const container = containerRef.current
133
+ const scrollMarker = scrollMarkerRef.current
134
+ if (!article || !container || !scrollMarker) return
135
+
136
+ const { top, height } = article.getBoundingClientRect()
137
+ const { height: cHeight } = container.getBoundingClientRect()
138
+ const viewportHeight = window.innerHeight
139
+ const { maxPosition: maxPos } = calculateTickBounds(cHeight)
140
+
141
+ let newScrollPos: number
142
+ if (top > 0) {
143
+ newScrollPos = 0
144
+ } else {
145
+ const scrolled = Math.abs(top)
146
+ const scrollRange = height - viewportHeight
147
+ const progress = scrollRange > 0 ? Math.min(1, scrolled / scrollRange) : 0
148
+ const rawPos = progress * maxPos
149
+ newScrollPos = snapToTick(rawPos, maxPos)
150
+ }
151
+
152
+ const prevScrollPos = scrollPosRef.current
153
+ if (newScrollPos !== prevScrollPos) {
154
+ scrollPosRef.current = newScrollPos
155
+ scrollMarker.style.top = `${newScrollPos}px`
156
+
157
+ const textEl = scrollMarker.querySelector('[data-scroll-text]')
158
+ if (textEl) {
159
+ textEl.textContent = (maxPos > 0 ? newScrollPos / maxPos : 0).toFixed(2)
160
+ }
161
+
162
+ const tickLines = container.querySelectorAll('[data-tick-line]')
163
+ tickLines.forEach((tick) => {
164
+ const tickPos = Number(tick.getAttribute('data-tick-pos'))
165
+ if (tickPos < newScrollPos) {
166
+ tick.classList.remove(styles.tickLineAfter)
167
+ tick.classList.add(styles.tickLineBefore)
168
+ } else {
169
+ tick.classList.remove(styles.tickLineBefore)
170
+ tick.classList.add(styles.tickLineAfter)
171
+ }
172
+ })
173
+ }
174
+ }, [])
175
+
176
+ useEffect(() => {
177
+ recalcHeadings()
178
+ handleScroll()
179
+ setReady(true)
180
+
181
+ const article = document.querySelector(ARTICLE_SELECTOR)
182
+ let ro: ResizeObserver | undefined
183
+ if (article) {
184
+ ro = new ResizeObserver(() => {
185
+ recalcHeadings()
186
+ handleScroll()
187
+ })
188
+ ro.observe(article)
189
+ }
190
+
191
+ window.addEventListener('resize', recalcHeadings)
192
+ window.addEventListener('scroll', handleScroll, { passive: true })
193
+
194
+ return () => {
195
+ ro?.disconnect()
196
+ window.removeEventListener('resize', recalcHeadings)
197
+ window.removeEventListener('scroll', handleScroll)
198
+ }
199
+ }, [recalcHeadings, handleScroll])
200
+
201
+ const scrollToTick = (y: number): void => {
202
+ const article = document.querySelector(ARTICLE_SELECTOR)
203
+ if (!article || maxPosition === 0) return
204
+
205
+ const articleBox = article.getBoundingClientRect()
206
+ const articleTop = articleBox.top + window.scrollY
207
+ const progress = y / maxPosition
208
+ const articlePos = progress * articleBox.height
209
+ const targetScroll = articleTop + articlePos - NAV_HEIGHT
210
+
211
+ window.scrollTo({ top: Math.max(0, targetScroll), behavior: 'smooth' })
212
+ }
213
+
214
+ const scrollToHeading = (id: string): void => {
215
+ const element = document.getElementById(id)
216
+ if (!element) return
217
+
218
+ const elementTop = element.getBoundingClientRect().top + window.scrollY
219
+ window.scrollTo({ top: Math.max(0, elementTop - NAV_HEIGHT), behavior: 'smooth' })
220
+ }
221
+
222
+ const ticks = useMemo(
223
+ () => Array.from({ length: numTicks }, (_, i) => i * TICK_HEIGHT),
224
+ [numTicks]
225
+ )
226
+
227
+ if (!isScrollable || ticks.length < 2) {
228
+ return <div ref={containerRef} className={styles.container} />
229
+ }
230
+
231
+ return (
232
+ <div ref={containerRef} className={styles.container}>
233
+ <div className={styles.inner}>
234
+ {ticks.map((y, i) => (
235
+ <div key={`tick-${i}`} style={{ top: `${y}px` }} className={styles.tickContainer}>
236
+ <div data-tick-line data-tick-pos={y} className={cx(styles.tickLine, styles.tickLineAfter)} />
237
+ <div className={styles.tickClickable} onClick={() => scrollToTick(y)} />
238
+ </div>
239
+ ))}
240
+
241
+ {headings.map((h, i) => (
242
+ <div key={h.id || i} className={styles.headingContainer}>
243
+ <div
244
+ className={styles.headingLabel}
245
+ style={{
246
+ top: `${h.yPosition - 6}px`,
247
+ right: '24px',
248
+ zIndex: h.level < 4 ? 10 : 0,
249
+ transitionDelay: `${50 * i}ms`,
250
+ }}
251
+ >
252
+ <a
253
+ href={h.url}
254
+ className={styles.headingLink}
255
+ onClick={(e) => {
256
+ e.preventDefault()
257
+ e.stopPropagation()
258
+ scrollToHeading(h.id)
259
+ }}
260
+ >
261
+ {h.title}
262
+ </a>
263
+ </div>
264
+ </div>
265
+ ))}
266
+
267
+ {headings.map((h, i) => (
268
+ <div
269
+ key={`line-${i}`}
270
+ className={styles.connectingLine}
271
+ style={{
272
+ top: `${h.yPosition}px`,
273
+ width: `${Math.max(4, (3 - h.level) * 4 + 12)}px`,
274
+ }}
275
+ />
276
+ ))}
277
+
278
+ <div
279
+ ref={scrollMarkerRef}
280
+ className={cx(
281
+ styles.scrollMarkerContainer,
282
+ ready ? styles.scrollMarkerContainerReady : styles.scrollMarkerContainerNotReady
283
+ )}
284
+ style={{ top: '0px' }}
285
+ >
286
+ <div className={styles.scrollMarkerLine} />
287
+ <span data-scroll-text className={styles.scrollMarkerText}>
288
+ 0.00
289
+ </span>
290
+ </div>
291
+ </div>
292
+ </div>
293
+ )
294
+ }
@@ -0,0 +1,8 @@
1
+ import { Layout } from './Layout'
2
+ import { Page } from './Page'
3
+ import type { Theme } from '@/types'
4
+
5
+ export const paperTheme: Theme = {
6
+ Layout,
7
+ Page,
8
+ }