@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.
@@ -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
+ }