@acmekit/docs-app 2.13.43
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.
- package/dist/entry-server.d.mts +5 -0
- package/dist/entry-server.d.ts +5 -0
- package/dist/entry-server.js +52 -0
- package/dist/entry-server.mjs +26 -0
- package/dist/index.d.mts +15 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +507 -0
- package/dist/index.mjs +490 -0
- package/package.json +50 -0
- package/src/__tests__/build-routes.spec.ts +134 -0
- package/src/__tests__/search-scoring.spec.ts +144 -0
- package/src/components/DocPage.tsx +31 -0
- package/src/components/HomePage.tsx +139 -0
- package/src/components/Layout.tsx +103 -0
- package/src/components/MDXContent.tsx +11 -0
- package/src/components/RouteErrorBoundary.tsx +22 -0
- package/src/components/SearchModal.tsx +167 -0
- package/src/docs-app.tsx +99 -0
- package/src/entry-server.tsx +29 -0
- package/src/index.ts +2 -0
- package/src/module.d.ts +21 -0
- package/src/providers/DocsProviders.tsx +66 -0
- package/src/routes/build-routes.ts +13 -0
- package/src/types.ts +7 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the scoreEntry function from SearchModal.
|
|
5
|
+
* We extract the pure function logic here to test without React dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Replicate the pure function from SearchModal.tsx
|
|
9
|
+
function scoreEntry(
|
|
10
|
+
entry: { title: string; description: string; body: string },
|
|
11
|
+
words: string[]
|
|
12
|
+
): number {
|
|
13
|
+
let score = 0
|
|
14
|
+
const title = entry.title.toLowerCase()
|
|
15
|
+
const desc = entry.description.toLowerCase()
|
|
16
|
+
const body = entry.body.toLowerCase()
|
|
17
|
+
|
|
18
|
+
for (const word of words) {
|
|
19
|
+
if (title.includes(word)) score += 3
|
|
20
|
+
if (desc.includes(word)) score += 2
|
|
21
|
+
if (body.includes(word)) score += 1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return score
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("scoreEntry", () => {
|
|
28
|
+
it("should return 0 for no matching words", () => {
|
|
29
|
+
const entry = {
|
|
30
|
+
title: "Getting Started",
|
|
31
|
+
description: "How to get started",
|
|
32
|
+
body: "Follow these steps",
|
|
33
|
+
}
|
|
34
|
+
expect(scoreEntry(entry, ["unrelated"])).toBe(0)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("should score 3 points for title match", () => {
|
|
38
|
+
const entry = {
|
|
39
|
+
title: "Authentication Guide",
|
|
40
|
+
description: "",
|
|
41
|
+
body: "",
|
|
42
|
+
}
|
|
43
|
+
expect(scoreEntry(entry, ["authentication"])).toBe(3)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("should score 2 points for description match", () => {
|
|
47
|
+
const entry = {
|
|
48
|
+
title: "Guide",
|
|
49
|
+
description: "How to configure authentication",
|
|
50
|
+
body: "",
|
|
51
|
+
}
|
|
52
|
+
expect(scoreEntry(entry, ["authentication"])).toBe(2)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("should score 1 point for body match", () => {
|
|
56
|
+
const entry = {
|
|
57
|
+
title: "Guide",
|
|
58
|
+
description: "A guide",
|
|
59
|
+
body: "Set up authentication in your project",
|
|
60
|
+
}
|
|
61
|
+
expect(scoreEntry(entry, ["authentication"])).toBe(1)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("should accumulate scores across all fields", () => {
|
|
65
|
+
const entry = {
|
|
66
|
+
title: "auth setup",
|
|
67
|
+
description: "Configure auth for your app",
|
|
68
|
+
body: "Auth is important for security",
|
|
69
|
+
}
|
|
70
|
+
// "auth" matches title (3) + description (2) + body (1) = 6
|
|
71
|
+
expect(scoreEntry(entry, ["auth"])).toBe(6)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("should accumulate scores across multiple words", () => {
|
|
75
|
+
const entry = {
|
|
76
|
+
title: "Authentication Guide",
|
|
77
|
+
description: "Setup steps",
|
|
78
|
+
body: "Follow the guide",
|
|
79
|
+
}
|
|
80
|
+
// "authentication" in title (3), "guide" in title (3) + body (1) = 7
|
|
81
|
+
expect(scoreEntry(entry, ["authentication", "guide"])).toBe(7)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("should be case insensitive", () => {
|
|
85
|
+
const entry = {
|
|
86
|
+
title: "Getting Started",
|
|
87
|
+
description: "QUICK START",
|
|
88
|
+
body: "Start here",
|
|
89
|
+
}
|
|
90
|
+
expect(scoreEntry(entry, ["start"])).toBeGreaterThan(0)
|
|
91
|
+
expect(scoreEntry(entry, ["START"])).toBe(0) // words should be lowercased by caller
|
|
92
|
+
expect(scoreEntry(entry, ["getting"])).toBe(3)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("should handle empty words array", () => {
|
|
96
|
+
const entry = {
|
|
97
|
+
title: "Test",
|
|
98
|
+
description: "Test",
|
|
99
|
+
body: "Test",
|
|
100
|
+
}
|
|
101
|
+
expect(scoreEntry(entry, [])).toBe(0)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("should handle empty entry fields", () => {
|
|
105
|
+
const entry = {
|
|
106
|
+
title: "",
|
|
107
|
+
description: "",
|
|
108
|
+
body: "",
|
|
109
|
+
}
|
|
110
|
+
expect(scoreEntry(entry, ["test"])).toBe(0)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("should match partial words (substring matching)", () => {
|
|
114
|
+
const entry = {
|
|
115
|
+
title: "Authentication",
|
|
116
|
+
description: "",
|
|
117
|
+
body: "",
|
|
118
|
+
}
|
|
119
|
+
// "auth" is a substring of "authentication"
|
|
120
|
+
expect(scoreEntry(entry, ["auth"])).toBe(3)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("should weight title higher than description higher than body", () => {
|
|
124
|
+
// Same word appearing only in title vs only in description vs only in body
|
|
125
|
+
const titleOnly = scoreEntry(
|
|
126
|
+
{ title: "test", description: "other", body: "other" },
|
|
127
|
+
["test"]
|
|
128
|
+
)
|
|
129
|
+
const descOnly = scoreEntry(
|
|
130
|
+
{ title: "other", description: "test", body: "other" },
|
|
131
|
+
["test"]
|
|
132
|
+
)
|
|
133
|
+
const bodyOnly = scoreEntry(
|
|
134
|
+
{ title: "other", description: "other", body: "test" },
|
|
135
|
+
["test"]
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
expect(titleOnly).toBe(3)
|
|
139
|
+
expect(descOnly).toBe(2)
|
|
140
|
+
expect(bodyOnly).toBe(1)
|
|
141
|
+
expect(titleOnly).toBeGreaterThan(descOnly)
|
|
142
|
+
expect(descOnly).toBeGreaterThan(bodyOnly)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from "react"
|
|
2
|
+
import { useLocation } from "react-router-dom"
|
|
3
|
+
import { useSiteConfig } from "@acmekit/docs-ui"
|
|
4
|
+
import { MDXContent } from "./MDXContent"
|
|
5
|
+
|
|
6
|
+
type DocPageProps = {
|
|
7
|
+
children: React.ReactNode
|
|
8
|
+
frontmatter?: Record<string, any>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function DocPage({ children, frontmatter }: DocPageProps) {
|
|
12
|
+
const { setFrontmatter, setToc } = useSiteConfig()
|
|
13
|
+
const location = useLocation()
|
|
14
|
+
const articleRef = useRef<HTMLElement>(null)
|
|
15
|
+
|
|
16
|
+
// Set frontmatter on route change
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
setFrontmatter({
|
|
19
|
+
...frontmatter,
|
|
20
|
+
generate_toc: true,
|
|
21
|
+
})
|
|
22
|
+
// Reset TOC so it regenerates for the new page
|
|
23
|
+
setToc([])
|
|
24
|
+
}, [location.pathname, frontmatter])
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<article ref={articleRef}>
|
|
28
|
+
<MDXContent>{children}</MDXContent>
|
|
29
|
+
</article>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import React, { Suspense } from "react"
|
|
2
|
+
import { Link } from "react-router-dom"
|
|
3
|
+
import { Loading } from "@acmekit/docs-ui"
|
|
4
|
+
import { config } from "virtual:acmekit/docs-config"
|
|
5
|
+
import { routes, components } from "virtual:acmekit/docs-routes"
|
|
6
|
+
import { DocPage } from "./DocPage"
|
|
7
|
+
|
|
8
|
+
export function HomePage() {
|
|
9
|
+
// Render the root index.mdx content if it exists
|
|
10
|
+
const rootRoute = routes.find((r: any) => r.path === "/")
|
|
11
|
+
const RootComponent = components["/"]
|
|
12
|
+
|
|
13
|
+
if (RootComponent) {
|
|
14
|
+
return (
|
|
15
|
+
<DocPage frontmatter={rootRoute?.frontmatter}>
|
|
16
|
+
<Suspense fallback={<Loading count={8} />}>
|
|
17
|
+
<RootComponent />
|
|
18
|
+
</Suspense>
|
|
19
|
+
</DocPage>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Areas mode: show area cards + plugin cards
|
|
24
|
+
if (config.areas?.length) {
|
|
25
|
+
const plugins = config.plugins || []
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="py-docs_2">
|
|
29
|
+
<h1 className="text-docs-h1 font-docs-heading text-acmekit-fg-base mb-docs_0.5">
|
|
30
|
+
{config.title}
|
|
31
|
+
</h1>
|
|
32
|
+
<p className="text-body-regular text-acmekit-fg-muted mb-docs_2">
|
|
33
|
+
Browse the documentation areas below.
|
|
34
|
+
</p>
|
|
35
|
+
<div className="grid gap-docs_1 sm:grid-cols-2 lg:grid-cols-3">
|
|
36
|
+
{config.areas.map((area: any) => {
|
|
37
|
+
const sidebar = config.sidebars?.find(
|
|
38
|
+
(s: any) => s.sidebar_id === area.sidebar_id
|
|
39
|
+
)
|
|
40
|
+
const count = sidebar?.items?.length ?? 0
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Link
|
|
44
|
+
key={area.id}
|
|
45
|
+
to={`/${area.id}`}
|
|
46
|
+
className="group block rounded-docs_DEFAULT border border-acmekit-border-base bg-acmekit-bg-base p-docs_1.5 no-underline transition-all hover:shadow-elevation-card-hover dark:hover:shadow-elevation-card-hover-dark"
|
|
47
|
+
>
|
|
48
|
+
<h3 className="text-compact-medium-plus text-acmekit-fg-base mb-docs_0.25 group-hover:text-acmekit-fg-interactive">
|
|
49
|
+
{area.title}
|
|
50
|
+
</h3>
|
|
51
|
+
<p className="text-compact-small text-acmekit-fg-muted">
|
|
52
|
+
{count} {count === 1 ? "section" : "sections"}
|
|
53
|
+
</p>
|
|
54
|
+
</Link>
|
|
55
|
+
)
|
|
56
|
+
})}
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{plugins.length > 0 && (
|
|
60
|
+
<>
|
|
61
|
+
<h2 className="text-docs-h2 font-docs-heading text-acmekit-fg-base mt-docs_2 mb-docs_1">
|
|
62
|
+
Plugins
|
|
63
|
+
</h2>
|
|
64
|
+
<div className="grid gap-docs_1 sm:grid-cols-2 lg:grid-cols-3">
|
|
65
|
+
{plugins.map((plugin: any) => {
|
|
66
|
+
const sidebar = config.sidebars?.find(
|
|
67
|
+
(s: any) => s.sidebar_id === plugin.sidebar_id
|
|
68
|
+
)
|
|
69
|
+
const count = sidebar?.items?.length ?? 0
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Link
|
|
73
|
+
key={plugin.slug}
|
|
74
|
+
to={`/plugins/${plugin.slug}`}
|
|
75
|
+
className="group block rounded-docs_DEFAULT border border-acmekit-border-base bg-acmekit-bg-base p-docs_1.5 no-underline transition-all hover:shadow-elevation-card-hover dark:hover:shadow-elevation-card-hover-dark"
|
|
76
|
+
>
|
|
77
|
+
<h3 className="text-compact-medium-plus text-acmekit-fg-base mb-docs_0.25 group-hover:text-acmekit-fg-interactive">
|
|
78
|
+
{plugin.title}
|
|
79
|
+
</h3>
|
|
80
|
+
{plugin.description && (
|
|
81
|
+
<p className="text-compact-small text-acmekit-fg-muted mb-docs_0.25">
|
|
82
|
+
{plugin.description}
|
|
83
|
+
</p>
|
|
84
|
+
)}
|
|
85
|
+
<p className="text-compact-small text-acmekit-fg-subtle">
|
|
86
|
+
{count} {count === 1 ? "page" : "pages"}
|
|
87
|
+
</p>
|
|
88
|
+
</Link>
|
|
89
|
+
)
|
|
90
|
+
})}
|
|
91
|
+
</div>
|
|
92
|
+
</>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Fallback: show category cards
|
|
99
|
+
const categories = (config.sidebars?.[0]?.items || []).filter(
|
|
100
|
+
(item: any) => item.type === "category" && item.children?.length
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="py-docs_2">
|
|
105
|
+
<h1 className="text-docs-h1 font-docs-heading text-acmekit-fg-base mb-docs_0.5">
|
|
106
|
+
{config.title}
|
|
107
|
+
</h1>
|
|
108
|
+
<p className="text-body-regular text-acmekit-fg-muted mb-docs_2">
|
|
109
|
+
Browse the documentation to get started.
|
|
110
|
+
</p>
|
|
111
|
+
{categories.length > 0 && (
|
|
112
|
+
<div className="grid gap-docs_1 sm:grid-cols-2 lg:grid-cols-3">
|
|
113
|
+
{categories.map((cat: any) => {
|
|
114
|
+
const firstLink = cat.children?.find(
|
|
115
|
+
(c: any) => c.type === "link" && c.path
|
|
116
|
+
)
|
|
117
|
+
const targetPath = firstLink?.path || "/"
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<Link
|
|
121
|
+
key={cat.title}
|
|
122
|
+
to={targetPath}
|
|
123
|
+
className="group block rounded-docs_DEFAULT border border-acmekit-border-base bg-acmekit-bg-base p-docs_1.5 no-underline transition-all hover:shadow-elevation-card-hover dark:hover:shadow-elevation-card-hover-dark"
|
|
124
|
+
>
|
|
125
|
+
<h3 className="text-compact-medium-plus text-acmekit-fg-base mb-docs_0.25 group-hover:text-acmekit-fg-interactive">
|
|
126
|
+
{cat.title}
|
|
127
|
+
</h3>
|
|
128
|
+
<p className="text-compact-small text-acmekit-fg-muted">
|
|
129
|
+
{cat.children?.length}{" "}
|
|
130
|
+
{cat.children?.length === 1 ? "article" : "articles"}
|
|
131
|
+
</p>
|
|
132
|
+
</Link>
|
|
133
|
+
)
|
|
134
|
+
})}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { Outlet, useLocation } from "react-router-dom"
|
|
3
|
+
import {
|
|
4
|
+
Sidebar,
|
|
5
|
+
MainNav,
|
|
6
|
+
ContentMenu,
|
|
7
|
+
Breadcrumbs,
|
|
8
|
+
Footer,
|
|
9
|
+
useIsBrowser,
|
|
10
|
+
useLayout,
|
|
11
|
+
useSidebar,
|
|
12
|
+
useSiteConfig,
|
|
13
|
+
} from "@acmekit/docs-ui"
|
|
14
|
+
import clsx from "clsx"
|
|
15
|
+
|
|
16
|
+
export function Layout() {
|
|
17
|
+
const { isBrowser } = useIsBrowser()
|
|
18
|
+
const { desktopSidebarOpen, sidebars } = useSidebar()
|
|
19
|
+
const { mainContentRef, showCollapsedNavbar } = useLayout()
|
|
20
|
+
const { frontmatter } = useSiteConfig()
|
|
21
|
+
const { pathname } = useLocation()
|
|
22
|
+
|
|
23
|
+
const isAreasRoot = pathname === "/" && sidebars.length > 1
|
|
24
|
+
|
|
25
|
+
React.useEffect(() => {
|
|
26
|
+
if (!isBrowser) return
|
|
27
|
+
const rootLayout = document.getElementById("root-layout")
|
|
28
|
+
if (desktopSidebarOpen && !isAreasRoot) {
|
|
29
|
+
rootLayout?.classList.add("lg:grid-cols-[221px_1fr]")
|
|
30
|
+
} else {
|
|
31
|
+
rootLayout?.classList.remove("lg:grid-cols-[221px_1fr]")
|
|
32
|
+
}
|
|
33
|
+
}, [desktopSidebarOpen, isBrowser, isAreasRoot])
|
|
34
|
+
|
|
35
|
+
const showContentMenu = !frontmatter.hide_content_menu && !isAreasRoot
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
className={clsx(
|
|
40
|
+
"bg-acmekit-bg-subtle font-base text-medium w-full",
|
|
41
|
+
"text-acmekit-fg-base",
|
|
42
|
+
"h-full overflow-hidden",
|
|
43
|
+
"grid grid-cols-1 lg:mx-auto",
|
|
44
|
+
!isAreasRoot && "lg:grid-cols-[221px_1fr]"
|
|
45
|
+
)}
|
|
46
|
+
id="root-layout"
|
|
47
|
+
>
|
|
48
|
+
{!isAreasRoot && <Sidebar />}
|
|
49
|
+
<div className="relative h-screen flex">
|
|
50
|
+
<div
|
|
51
|
+
className={clsx(
|
|
52
|
+
"relative max-w-full",
|
|
53
|
+
"h-full flex-1",
|
|
54
|
+
"flex flex-col",
|
|
55
|
+
"gap-docs_0.5 lg:py-docs_0.25 lg:mr-docs_0.25 scroll-m-docs_0.25",
|
|
56
|
+
!desktopSidebarOpen && "lg:ml-docs_0.25"
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<div
|
|
60
|
+
className={clsx(
|
|
61
|
+
"bg-acmekit-bg-base",
|
|
62
|
+
"flex-col items-center",
|
|
63
|
+
"h-full w-full",
|
|
64
|
+
"overflow-y-scroll overflow-x-hidden",
|
|
65
|
+
"md:rounded-docs_DEFAULT",
|
|
66
|
+
"shadow-elevation-card-rest dark:shadow-elevation-card-rest-dark"
|
|
67
|
+
)}
|
|
68
|
+
id="main"
|
|
69
|
+
ref={mainContentRef}
|
|
70
|
+
>
|
|
71
|
+
<MainNav />
|
|
72
|
+
<div
|
|
73
|
+
className={clsx(
|
|
74
|
+
"pt-docs_4 lg:pt-docs_6 pb-docs_8 lg:pb-docs_4",
|
|
75
|
+
showContentMenu && "grid grid-cols-1 lg:mx-auto",
|
|
76
|
+
desktopSidebarOpen && showContentMenu && "lg:grid-cols-[1fr_221px]"
|
|
77
|
+
)}
|
|
78
|
+
id="content"
|
|
79
|
+
>
|
|
80
|
+
<div className="flex justify-center">
|
|
81
|
+
<div
|
|
82
|
+
className={clsx(
|
|
83
|
+
"w-full h-fit",
|
|
84
|
+
"max-w-inner-content-xs sm:max-w-inner-content-sm",
|
|
85
|
+
"md:max-w-inner-content-md lg:max-w-inner-content-lg",
|
|
86
|
+
"xl:max-w-inner-content-xl xxl:max-w-inner-content-xxl",
|
|
87
|
+
"xxxl:max-w-inner-content-xxxl",
|
|
88
|
+
"px-docs_1 md:px-docs_4 lg:px-0"
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
<Breadcrumbs />
|
|
92
|
+
<Outlet />
|
|
93
|
+
<Footer showPagination={true} />
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
{showContentMenu && <ContentMenu />}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { MDXProvider } from "@mdx-js/react"
|
|
3
|
+
import { MDXComponents } from "@acmekit/docs-ui"
|
|
4
|
+
|
|
5
|
+
type MDXContentProps = {
|
|
6
|
+
children: React.ReactNode
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function MDXContent({ children }: MDXContentProps) {
|
|
10
|
+
return <MDXProvider components={MDXComponents}>{children}</MDXProvider>
|
|
11
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { ErrorPage } from "@acmekit/docs-ui"
|
|
3
|
+
|
|
4
|
+
type State = { hasError: boolean }
|
|
5
|
+
|
|
6
|
+
export class RouteErrorBoundary extends React.Component<
|
|
7
|
+
{ children: React.ReactNode },
|
|
8
|
+
State
|
|
9
|
+
> {
|
|
10
|
+
state: State = { hasError: false }
|
|
11
|
+
|
|
12
|
+
static getDerivedStateFromError(): State {
|
|
13
|
+
return { hasError: true }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
render() {
|
|
17
|
+
if (this.state.hasError) {
|
|
18
|
+
return <ErrorPage />
|
|
19
|
+
}
|
|
20
|
+
return this.props.children
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from "react"
|
|
2
|
+
import { useNavigate } from "react-router-dom"
|
|
3
|
+
import { SearchInput, useSearch } from "@acmekit/docs-ui"
|
|
4
|
+
import { searchIndex } from "virtual:acmekit/docs-search"
|
|
5
|
+
|
|
6
|
+
type SearchResult = {
|
|
7
|
+
path: string
|
|
8
|
+
title: string
|
|
9
|
+
description: string
|
|
10
|
+
score: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function scoreEntry(
|
|
14
|
+
entry: { title: string; description: string; body: string },
|
|
15
|
+
words: string[]
|
|
16
|
+
): number {
|
|
17
|
+
let score = 0
|
|
18
|
+
const title = entry.title.toLowerCase()
|
|
19
|
+
const desc = entry.description.toLowerCase()
|
|
20
|
+
const body = entry.body.toLowerCase()
|
|
21
|
+
|
|
22
|
+
for (const word of words) {
|
|
23
|
+
if (title.includes(word)) score += 3
|
|
24
|
+
if (desc.includes(word)) score += 2
|
|
25
|
+
if (body.includes(word)) score += 1
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return score
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function SearchModal() {
|
|
32
|
+
const { isOpen, setIsOpen, modalRef } = useSearch()
|
|
33
|
+
const [query, setQuery] = useState("")
|
|
34
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
35
|
+
const navigate = useNavigate()
|
|
36
|
+
const searchWrapperRef = useRef<HTMLDivElement>(null)
|
|
37
|
+
|
|
38
|
+
const results: SearchResult[] = React.useMemo(() => {
|
|
39
|
+
if (!query.trim()) return []
|
|
40
|
+
|
|
41
|
+
const words = query
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
.split(/\s+/)
|
|
44
|
+
.filter((w) => w.length > 0)
|
|
45
|
+
|
|
46
|
+
return searchIndex
|
|
47
|
+
.map((entry) => ({
|
|
48
|
+
path: entry.path,
|
|
49
|
+
title: entry.title,
|
|
50
|
+
description: entry.description,
|
|
51
|
+
score: scoreEntry(entry, words),
|
|
52
|
+
}))
|
|
53
|
+
.filter((r) => r.score > 0)
|
|
54
|
+
.sort((a, b) => b.score - a.score)
|
|
55
|
+
.slice(0, 20)
|
|
56
|
+
}, [query])
|
|
57
|
+
|
|
58
|
+
// Reset active index when results change
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
setActiveIndex(0)
|
|
61
|
+
}, [results])
|
|
62
|
+
|
|
63
|
+
// Open/close via Cmd+K / Ctrl+K
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
66
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
67
|
+
e.preventDefault()
|
|
68
|
+
setIsOpen((prev) => !prev)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
document.addEventListener("keydown", onKeyDown)
|
|
72
|
+
return () => document.removeEventListener("keydown", onKeyDown)
|
|
73
|
+
}, [setIsOpen])
|
|
74
|
+
|
|
75
|
+
// Manage dialog open/close
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const dialog = modalRef.current
|
|
78
|
+
if (!dialog) return
|
|
79
|
+
|
|
80
|
+
if (isOpen) {
|
|
81
|
+
dialog.showModal()
|
|
82
|
+
searchWrapperRef.current?.querySelector<HTMLInputElement>("input")?.focus()
|
|
83
|
+
} else {
|
|
84
|
+
dialog.close()
|
|
85
|
+
setQuery("")
|
|
86
|
+
}
|
|
87
|
+
}, [isOpen, modalRef])
|
|
88
|
+
|
|
89
|
+
const navigateTo = useCallback(
|
|
90
|
+
(path: string) => {
|
|
91
|
+
navigate(path)
|
|
92
|
+
setIsOpen(false)
|
|
93
|
+
},
|
|
94
|
+
[navigate, setIsOpen]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
function onKeyDown(e: React.KeyboardEvent) {
|
|
98
|
+
if (e.key === "ArrowDown") {
|
|
99
|
+
e.preventDefault()
|
|
100
|
+
setActiveIndex((i) => Math.min(i + 1, results.length - 1))
|
|
101
|
+
} else if (e.key === "ArrowUp") {
|
|
102
|
+
e.preventDefault()
|
|
103
|
+
setActiveIndex((i) => Math.max(i - 1, 0))
|
|
104
|
+
} else if (e.key === "Enter" && results[activeIndex]) {
|
|
105
|
+
e.preventDefault()
|
|
106
|
+
navigateTo(results[activeIndex].path)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<dialog
|
|
112
|
+
ref={modalRef}
|
|
113
|
+
className="fixed inset-0 z-50 m-0 h-full w-full max-h-full max-w-full bg-transparent p-0 backdrop:bg-acmekit-bg-overlay"
|
|
114
|
+
onClick={(e) => {
|
|
115
|
+
if (e.target === e.currentTarget) setIsOpen(false)
|
|
116
|
+
}}
|
|
117
|
+
onClose={() => setIsOpen(false)}
|
|
118
|
+
>
|
|
119
|
+
<div className="mx-auto mt-[15vh] w-full max-w-[540px] rounded-docs_DEFAULT border border-acmekit-border-base bg-acmekit-bg-base shadow-elevation-modal dark:shadow-elevation-modal-dark">
|
|
120
|
+
<div ref={searchWrapperRef} className="p-docs_1" onKeyDown={onKeyDown}>
|
|
121
|
+
<SearchInput
|
|
122
|
+
value={query}
|
|
123
|
+
onChange={setQuery}
|
|
124
|
+
placeholder="Search documentation..."
|
|
125
|
+
autoFocus
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{results.length > 0 && (
|
|
130
|
+
<ul className="max-h-[50vh] overflow-y-auto border-t border-acmekit-border-base p-docs_0.5">
|
|
131
|
+
{results.map((result, i) => (
|
|
132
|
+
<li key={result.path}>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
className={`w-full rounded-docs_sm px-docs_1 py-docs_0.5 text-left transition-colors ${
|
|
136
|
+
i === activeIndex
|
|
137
|
+
? "bg-acmekit-bg-base-hover"
|
|
138
|
+
: "hover:bg-acmekit-bg-base-hover"
|
|
139
|
+
}`}
|
|
140
|
+
onMouseEnter={() => setActiveIndex(i)}
|
|
141
|
+
onClick={() => navigateTo(result.path)}
|
|
142
|
+
>
|
|
143
|
+
<span className="text-compact-small-plus text-acmekit-fg-base block">
|
|
144
|
+
{result.title}
|
|
145
|
+
</span>
|
|
146
|
+
{result.description && (
|
|
147
|
+
<span className="text-compact-x-small text-acmekit-fg-muted block truncate">
|
|
148
|
+
{result.description}
|
|
149
|
+
</span>
|
|
150
|
+
)}
|
|
151
|
+
</button>
|
|
152
|
+
</li>
|
|
153
|
+
))}
|
|
154
|
+
</ul>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{query.trim() && results.length === 0 && (
|
|
158
|
+
<div className="border-t border-acmekit-border-base p-docs_1 text-center">
|
|
159
|
+
<span className="text-compact-small text-acmekit-fg-muted">
|
|
160
|
+
No results found
|
|
161
|
+
</span>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
</dialog>
|
|
166
|
+
)
|
|
167
|
+
}
|