diff --git a/app/utils/scrollToAnchor.ts b/app/utils/scrollToAnchor.ts
new file mode 100644
index 000000000..f0ec64be4
--- /dev/null
+++ b/app/utils/scrollToAnchor.ts
@@ -0,0 +1,45 @@
+export interface ScrollToAnchorOptions {
+ /** Custom scroll function (e.g., from useActiveTocItem) */
+ scrollFn?: (id: string) => void
+ /** Whether to update the URL hash (default: true) */
+ updateUrl?: boolean
+}
+
+/**
+ * Scroll to an element by ID, using a custom scroll function if provided,
+ * otherwise falling back to default scroll behavior with header offset.
+ *
+ * @param id - The element ID to scroll to
+ * @param options - Optional configuration for scroll behavior
+ */
+export function scrollToAnchor(id: string, options?: ScrollToAnchorOptions): void {
+ const { scrollFn, updateUrl = true } = options ?? {}
+
+ // Use custom scroll function if provided
+ if (scrollFn) {
+ scrollFn(id)
+ return
+ }
+
+ // Fallback: scroll with header offset
+ const element = document.getElementById(id)
+ if (!element) return
+
+ // Calculate scroll position with header offset (matches scroll-padding-top in main.css)
+ const HEADER_OFFSET = 80
+ const PKG_STICKY_HEADER_OFFSET = 52
+ const elementTop = element.getBoundingClientRect().top + window.scrollY
+ const targetScrollY = elementTop - (HEADER_OFFSET + PKG_STICKY_HEADER_OFFSET)
+
+ // Use scrollTo for precise control
+ window.scrollTo({
+ top: targetScrollY,
+ behavior: 'smooth',
+ })
+
+ // Update URL hash after initiating scroll
+ // Use replaceState to avoid triggering native scroll-to-anchor behavior
+ if (updateUrl) {
+ history.replaceState(null, '', `#${id}`)
+ }
+}
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 57361f72b..9c366162e 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -200,7 +200,8 @@
"readme": {
"title": "Readme",
"no_readme": "No README available.",
- "view_on_github": "View on GitHub"
+ "view_on_github": "View on GitHub",
+ "toc_title": "Outline"
},
"keywords_title": "Keywords",
"compatibility": "Compatibility",
diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json
index 57361f72b..9c366162e 100644
--- a/lunaria/files/en-US.json
+++ b/lunaria/files/en-US.json
@@ -200,7 +200,8 @@
"readme": {
"title": "Readme",
"no_readme": "No README available.",
- "view_on_github": "View on GitHub"
+ "view_on_github": "View on GitHub",
+ "toc_title": "Outline"
},
"keywords_title": "Keywords",
"compatibility": "Compatibility",
diff --git a/server/api/registry/readme/[...pkg].get.ts b/server/api/registry/readme/[...pkg].get.ts
index c523bbd01..d72b76442 100644
--- a/server/api/registry/readme/[...pkg].get.ts
+++ b/server/api/registry/readme/[...pkg].get.ts
@@ -107,7 +107,7 @@ export default defineCachedEventHandler(
}
if (!readmeContent || readmeContent === NPM_MISSING_README_SENTINEL) {
- return { html: '', playgroundLinks: [] }
+ return { html: '', playgroundLinks: [], toc: [] }
}
// Parse repository info for resolving relative URLs to GitHub
diff --git a/server/utils/readme.ts b/server/utils/readme.ts
index 66373985b..505989ed0 100644
--- a/server/utils/readme.ts
+++ b/server/utils/readme.ts
@@ -1,7 +1,7 @@
import { marked, type Tokens } from 'marked'
import sanitizeHtml from 'sanitize-html'
import { hasProtocol } from 'ufo'
-import type { ReadmeResponse } from '#shared/types/readme'
+import type { ReadmeResponse, TocItem } from '#shared/types/readme'
import { convertBlobToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers'
import { highlightCodeSync } from './shiki'
import { convertToEmoji } from '#shared/utils/emoji'
@@ -255,7 +255,7 @@ export async function renderReadmeHtml(
packageName: string,
repoInfo?: RepositoryInfo,
): Promise