camelmind 0.2.7 → 0.2.9
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/package.json +1 -1
- package/template/app/[...slug]/page.tsx +3 -1
- package/template/components/Nav/TopNav.tsx +1 -1
- package/template/components/Sidebar/Sidebar.tsx +87 -32
- package/template/components/mdx/Details.tsx +5 -4
- package/template/components/mdx/Tabs.tsx +27 -41
- package/template/lib/nav-types.ts +1 -0
- package/template/lib/remark-tabs.ts +85 -0
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getSectionForSlugFromConfig,
|
|
12
12
|
} from "@/lib/nav"
|
|
13
13
|
import { loadMdxFile } from "@/lib/mdx"
|
|
14
|
+
import { preprocessTabs } from "@/lib/remark-tabs"
|
|
14
15
|
import { getSession, hasAccess, pageRequiresAuth, shouldRedirectToLogin } from "@/lib/auth"
|
|
15
16
|
import { getConfig, isAuthEnabled, showLastUpdated, showLastUpdateAuthor, showFeedbackWidget } from "@/lib/config"
|
|
16
17
|
import { loadVersions, getVersionFromSlug, getNavForVersion } from "@/lib/versions"
|
|
@@ -97,7 +98,8 @@ export default async function DocPage({ params }: Props) {
|
|
|
97
98
|
|
|
98
99
|
const activeGroup = getGroupForSlugFromConfig(nav, fullSlug) as NavGroup | null
|
|
99
100
|
const sectionEntry = getSectionForSlugFromConfig(nav, fullSlug) as NavEntry | null
|
|
100
|
-
const { frontmatter, source, toc, lastUpdated, lastUpdatedAuthor } = loadMdxFile(navEntry.file)
|
|
101
|
+
const { frontmatter, source: rawSource, toc, lastUpdated, lastUpdatedAuthor } = loadMdxFile(navEntry.file)
|
|
102
|
+
const source = preprocessTabs(rawSource).replace(/\n+([ \t]*<\/Tab>)/g, "\n\n{' '}\n\n$1")
|
|
101
103
|
const isSectionRoot = sectionEntry?.slug === fullSlug
|
|
102
104
|
|
|
103
105
|
return (
|
|
@@ -60,7 +60,7 @@ export function TopNav({ nav, userRoles, userName, authEnabled = false, versions
|
|
|
60
60
|
<div className="hidden md:flex items-center gap-6 flex-1">
|
|
61
61
|
{nav.map((item) => {
|
|
62
62
|
if (isNavGroup(item)) {
|
|
63
|
-
const visibleItems = (item.items ?? []).filter((i) => canSee(i.roles))
|
|
63
|
+
const visibleItems = (item.items ?? []).filter((i) => canSee(i.roles) && !i.hidden_from_nav)
|
|
64
64
|
const directHref = item.slug ?? visibleItems[0]?.slug
|
|
65
65
|
|
|
66
66
|
if ((item.noDropdown || !item.items) && directHref) {
|
|
@@ -16,59 +16,83 @@ function canSee(roles: string[], userRoles: string[], authEnabled: boolean) {
|
|
|
16
16
|
return roles.length === 0 || roles.some((r) => userRoles.includes(r))
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Recursively check if any descendant matches the slug
|
|
20
|
+
function subtreeContains(items: NavChild[], slug: string): boolean {
|
|
21
|
+
return items.some(
|
|
22
|
+
(c) => c.slug === slug || (c.children ? subtreeContains(c.children, slug) : false)
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
// Doc page link (leaf node inside a section)
|
|
20
|
-
function DocLink({ item, currentSlug }: { item: NavChild; currentSlug: string }) {
|
|
27
|
+
function DocLink({ item, currentSlug, depth = 0 }: { item: NavChild; currentSlug: string; depth?: number }) {
|
|
21
28
|
const isActive = currentSlug === item.slug
|
|
29
|
+
const isNested = depth > 0
|
|
30
|
+
const wrapperIndent = isNested ? `${depth * 7}px` : undefined
|
|
31
|
+
|
|
22
32
|
return (
|
|
23
|
-
<
|
|
33
|
+
<div style={wrapperIndent ? { paddingLeft: wrapperIndent } : {}}>
|
|
24
34
|
<Link
|
|
25
35
|
href={item.slug}
|
|
26
36
|
style={isActive ? { borderColor: "var(--cm-active-border)", color: "var(--cm-active)" } : {}}
|
|
27
|
-
className={`block py-1.5
|
|
37
|
+
className={`block py-1.5 pr-3 text-sm transition-colors ${
|
|
38
|
+
isNested
|
|
39
|
+
? "pl-4 border-l-2"
|
|
40
|
+
: "pl-2"
|
|
41
|
+
} ${
|
|
28
42
|
isActive
|
|
29
43
|
? "font-medium bg-black/5 dark:bg-white/10"
|
|
30
|
-
:
|
|
44
|
+
: isNested
|
|
45
|
+
? "border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:border-gray-300 dark:hover:border-gray-600"
|
|
46
|
+
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
|
|
31
47
|
}`}
|
|
32
48
|
>
|
|
33
49
|
{item.label}
|
|
34
50
|
</Link>
|
|
35
|
-
</
|
|
51
|
+
</div>
|
|
36
52
|
)
|
|
37
53
|
}
|
|
38
54
|
|
|
39
|
-
// Collapsible section row
|
|
55
|
+
// Collapsible section row — renders children as DocLink or nested SectionRow
|
|
40
56
|
function SectionRow({
|
|
41
57
|
section,
|
|
42
58
|
currentSlug,
|
|
43
59
|
userRoles,
|
|
44
60
|
authEnabled = false,
|
|
61
|
+
depth = 0,
|
|
45
62
|
}: {
|
|
46
63
|
section: NavChild
|
|
47
64
|
currentSlug: string
|
|
48
65
|
userRoles: string[]
|
|
49
66
|
authEnabled?: boolean
|
|
67
|
+
depth?: number
|
|
50
68
|
}) {
|
|
51
69
|
const docs = (section.children ?? []).filter((c) => canSee(c.roles, userRoles, authEnabled))
|
|
52
70
|
const hasChildren = docs.length > 0
|
|
53
71
|
|
|
54
|
-
// Open
|
|
72
|
+
// Open if the current page is anywhere in the subtree
|
|
55
73
|
const containsCurrent =
|
|
56
|
-
currentSlug === section.slug ||
|
|
57
|
-
docs.some((d) => currentSlug === d.slug)
|
|
74
|
+
currentSlug === section.slug || subtreeContains(docs, currentSlug)
|
|
58
75
|
|
|
59
76
|
const [open, setOpen] = useState(containsCurrent)
|
|
60
77
|
|
|
78
|
+
const isNested = depth > 0
|
|
79
|
+
const wrapperIndent = isNested ? `${depth * 7}px` : undefined
|
|
80
|
+
|
|
61
81
|
// No children — render as a plain link, no arrow
|
|
62
82
|
if (!hasChildren) {
|
|
63
83
|
return (
|
|
64
|
-
<div className="mb-0.5">
|
|
84
|
+
<div className="mb-0.5" style={wrapperIndent ? { paddingLeft: wrapperIndent } : {}}>
|
|
65
85
|
<Link
|
|
66
86
|
href={section.slug}
|
|
67
87
|
style={currentSlug === section.slug ? { borderColor: "var(--cm-active-border)", color: "var(--cm-active)" } : {}}
|
|
68
|
-
|
|
88
|
+
className={`block py-1.5 pr-2 text-sm transition-colors ${
|
|
89
|
+
isNested ? "pl-4 border-l-2" : "pl-2"
|
|
90
|
+
} ${
|
|
69
91
|
currentSlug === section.slug
|
|
70
92
|
? "font-medium bg-black/5 dark:bg-white/10"
|
|
71
|
-
:
|
|
93
|
+
: isNested
|
|
94
|
+
? "border-transparent text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:border-gray-300 dark:hover:border-gray-600"
|
|
95
|
+
: "text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
|
72
96
|
}`}
|
|
73
97
|
>
|
|
74
98
|
{section.label}
|
|
@@ -78,30 +102,61 @@ function SectionRow({
|
|
|
78
102
|
}
|
|
79
103
|
|
|
80
104
|
return (
|
|
81
|
-
<div className="mb-0.5">
|
|
82
|
-
{/* Section header —
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
className=
|
|
105
|
+
<div className="mb-0.5" style={wrapperIndent ? { paddingLeft: wrapperIndent } : {}}>
|
|
106
|
+
{/* Section header — link navigates, chevron toggles collapse */}
|
|
107
|
+
<div
|
|
108
|
+
style={isNested && currentSlug === section.slug ? { borderColor: "var(--cm-active-border)" } : {}}
|
|
109
|
+
className={`flex items-start rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors ${
|
|
110
|
+
isNested ? `border-l-2 ${currentSlug === section.slug ? "" : "border-transparent"}` : ""
|
|
111
|
+
}`}
|
|
86
112
|
>
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
113
|
+
<Link
|
|
114
|
+
href={section.slug}
|
|
115
|
+
style={currentSlug === section.slug ? { color: "var(--cm-active)" } : {}}
|
|
116
|
+
className={`flex-1 min-w-0 py-1.5 text-sm leading-snug transition-colors ${
|
|
117
|
+
isNested ? "pl-4" : "pl-2"
|
|
118
|
+
} ${
|
|
119
|
+
currentSlug === section.slug
|
|
120
|
+
? "font-medium"
|
|
121
|
+
: "text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
|
122
|
+
}`}
|
|
94
123
|
>
|
|
95
|
-
|
|
96
|
-
</
|
|
97
|
-
|
|
124
|
+
{section.label}
|
|
125
|
+
</Link>
|
|
126
|
+
<button
|
|
127
|
+
onClick={() => setOpen((o) => !o)}
|
|
128
|
+
className="shrink-0 p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
|
129
|
+
aria-label={open ? "Collapse" : "Expand"}
|
|
130
|
+
>
|
|
131
|
+
<svg
|
|
132
|
+
className={`w-3.5 h-3.5 transition-transform ${open ? "rotate-0" : "rotate-180"}`}
|
|
133
|
+
fill="none"
|
|
134
|
+
viewBox="0 0 24 24"
|
|
135
|
+
stroke="currentColor"
|
|
136
|
+
strokeWidth={2.5}
|
|
137
|
+
>
|
|
138
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
|
139
|
+
</svg>
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
98
142
|
|
|
99
143
|
{open && (
|
|
100
|
-
<
|
|
101
|
-
{docs.map((doc) =>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
144
|
+
<div className="mt-0.5 mb-1">
|
|
145
|
+
{docs.map((doc) =>
|
|
146
|
+
(doc.children ?? []).filter((c) => canSee(c.roles, userRoles, authEnabled)).length > 0 ? (
|
|
147
|
+
<SectionRow
|
|
148
|
+
key={doc.slug}
|
|
149
|
+
section={doc}
|
|
150
|
+
currentSlug={currentSlug}
|
|
151
|
+
userRoles={userRoles}
|
|
152
|
+
authEnabled={authEnabled}
|
|
153
|
+
depth={depth + 1}
|
|
154
|
+
/>
|
|
155
|
+
) : (
|
|
156
|
+
<DocLink key={doc.slug} item={doc} currentSlug={currentSlug} depth={depth + 1} />
|
|
157
|
+
)
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
105
160
|
)}
|
|
106
161
|
</div>
|
|
107
162
|
)
|
|
@@ -6,10 +6,11 @@ import { Link } from "lucide-react"
|
|
|
6
6
|
type Props = {
|
|
7
7
|
id?: string
|
|
8
8
|
summary: string
|
|
9
|
+
bold?: boolean
|
|
9
10
|
children: React.ReactNode
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
export function Details({ id, summary, children }: Props) {
|
|
13
|
+
export function Details({ id, summary, bold = false, children }: Props) {
|
|
13
14
|
const ref = useRef<HTMLDetailsElement>(null)
|
|
14
15
|
|
|
15
16
|
useEffect(() => {
|
|
@@ -35,10 +36,10 @@ export function Details({ id, summary, children }: Props) {
|
|
|
35
36
|
<details
|
|
36
37
|
id={id}
|
|
37
38
|
ref={ref}
|
|
38
|
-
className="group my-3 rounded-lg border border-gray-200 bg-white
|
|
39
|
+
className="group my-3 rounded-lg border border-gray-200 bg-white"
|
|
39
40
|
>
|
|
40
41
|
<summary className="flex items-center justify-between gap-3 px-4 py-3 cursor-pointer select-none list-none hover:bg-gray-50 transition-colors">
|
|
41
|
-
<span className=
|
|
42
|
+
<span className={`text-sm text-gray-800 ${bold ? "font-semibold" : "font-medium"}`}>{summary}</span>
|
|
42
43
|
<div className="flex items-center gap-2 shrink-0">
|
|
43
44
|
{id && (
|
|
44
45
|
<button
|
|
@@ -57,7 +58,7 @@ export function Details({ id, summary, children }: Props) {
|
|
|
57
58
|
</svg>
|
|
58
59
|
</div>
|
|
59
60
|
</summary>
|
|
60
|
-
<div className="px-4 pb-4 pt-2 text-sm text-gray-700 border-t border-gray-100">
|
|
61
|
+
<div className="px-4 pb-4 pt-2 text-sm text-gray-700 border-t border-gray-100 overflow-x-auto">
|
|
61
62
|
{children}
|
|
62
63
|
</div>
|
|
63
64
|
</details>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { useState, Children, isValidElement } from "react"
|
|
3
|
+
import { useState, useEffect, useId, useCallback, Children, isValidElement } from "react"
|
|
4
4
|
|
|
5
5
|
type TabProps = {
|
|
6
6
|
label: string
|
|
@@ -19,51 +19,37 @@ export function Tabs({ children }: { children: React.ReactNode }) {
|
|
|
19
19
|
)
|
|
20
20
|
) as React.ReactElement<TabProps>[]
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// Stable per-instance group id — SSR-safe, unique across multiple Tabs on the same page
|
|
23
|
+
const uid = useId().replace(/[^a-z0-9]/g, "")
|
|
24
|
+
|
|
25
|
+
const getIndexFromHash = useCallback(() => {
|
|
26
|
+
if (typeof window === "undefined") return 0
|
|
27
|
+
const match = window.location.hash.match(new RegExp(`^#__tabbed_${uid}_(\\d+)$`))
|
|
28
|
+
if (!match) return 0
|
|
29
|
+
const idx = parseInt(match[1], 10) - 1 // hash is 1-based
|
|
30
|
+
return idx >= 0 && idx < tabs.length ? idx : 0
|
|
31
|
+
}, [tabs.length, uid])
|
|
32
|
+
|
|
33
|
+
const [active, setActive] = useState(() => getIndexFromHash())
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const onHashChange = () => setActive(getIndexFromHash())
|
|
37
|
+
window.addEventListener("hashchange", onHashChange)
|
|
38
|
+
return () => window.removeEventListener("hashchange", onHashChange)
|
|
39
|
+
}, [getIndexFromHash])
|
|
40
|
+
|
|
41
|
+
const handleClick = (i: number) => {
|
|
42
|
+
setActive(i)
|
|
43
|
+
history.replaceState(null, "", `#__tabbed_${uid}_${i + 1}`)
|
|
44
|
+
}
|
|
23
45
|
|
|
24
46
|
if (tabs.length === 0) return null
|
|
25
47
|
|
|
26
48
|
return (
|
|
27
|
-
<div className="my-4
|
|
28
|
-
{/*
|
|
29
|
-
<div data-print="hide" className="rounded-lg border border-gray-200 overflow-hidden">
|
|
30
|
-
{/* Tab bar */}
|
|
31
|
-
<div className="flex border-b border-gray-200 bg-gray-50">
|
|
32
|
-
{tabs.map((tab, i) => (
|
|
33
|
-
<button
|
|
34
|
-
key={i}
|
|
35
|
-
onClick={() => setActive(i)}
|
|
36
|
-
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
37
|
-
i === active
|
|
38
|
-
? "border-gray-900 text-gray-900 bg-white"
|
|
39
|
-
: "border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
|
40
|
-
}`}
|
|
41
|
-
>
|
|
42
|
-
{tab.props.label}
|
|
43
|
-
</button>
|
|
44
|
-
))}
|
|
45
|
-
</div>
|
|
46
|
-
{/* Active tab content */}
|
|
47
|
-
<div className="p-4 prose prose-gray max-w-none">
|
|
48
|
-
{tabs[active]?.props.children}
|
|
49
|
-
</div>
|
|
50
|
-
</div>
|
|
51
|
-
|
|
52
|
-
{/* PDF: all panels expanded — hidden on screen via class, shown by removing it in generator */}
|
|
53
|
-
<div data-print="show" className="hidden">
|
|
54
|
-
{tabs.map((tab, i) => (
|
|
55
|
-
<div key={i} className="mb-4">
|
|
56
|
-
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2 pb-1 border-b border-gray-200">
|
|
57
|
-
{tab.props.label}
|
|
58
|
-
</div>
|
|
59
|
-
<div className="prose prose-gray max-w-none">
|
|
60
|
-
{tab.props.children}
|
|
61
|
-
</div>
|
|
62
|
-
</div>
|
|
63
|
-
))}
|
|
64
|
-
</div>
|
|
49
|
+
<div className="my-4">
|
|
50
|
+
{/* rest of your JSX stays the same */}
|
|
65
51
|
</div>
|
|
66
52
|
)
|
|
67
53
|
}
|
|
68
54
|
|
|
69
|
-
Tab.displayName = "Tab"
|
|
55
|
+
Tab.displayName = "Tab"
|
|
@@ -11,6 +11,7 @@ export type NavEntry = {
|
|
|
11
11
|
slug: string
|
|
12
12
|
file: string
|
|
13
13
|
roles: string[]
|
|
14
|
+
hidden_from_nav?: boolean // accessible by URL but excluded from dropdown and sidebar
|
|
14
15
|
section?: NavChild[] // renders as ALL CAPS category block with children below
|
|
15
16
|
children?: NavChild[] // renders as a plain collapsible row (no category label)
|
|
16
17
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Preprocessor that converts MkDocs-style tab syntax into <Tabs>/<Tab> JSX
|
|
2
|
+
// before the MDX compiler sees the source. This avoids all markdown-in-JSX
|
|
3
|
+
// indentation and blank-line rules that make hand-authoring tabs painful.
|
|
4
|
+
//
|
|
5
|
+
// Syntax:
|
|
6
|
+
//
|
|
7
|
+
// === "Tab Label"
|
|
8
|
+
// Content for this tab. Any markdown works — bullets, code, callouts, etc.
|
|
9
|
+
//
|
|
10
|
+
// === "Another Tab"
|
|
11
|
+
// More content.
|
|
12
|
+
//
|
|
13
|
+
// ===
|
|
14
|
+
//
|
|
15
|
+
// Rules:
|
|
16
|
+
// - === "Label" opens a tab (single or double quotes both work)
|
|
17
|
+
// - === closes the group (required)
|
|
18
|
+
// - No indentation needed inside tabs
|
|
19
|
+
// - Blank lines within tab content are fine
|
|
20
|
+
// - Multiple tab groups per document are supported
|
|
21
|
+
|
|
22
|
+
export function preprocessTabs(source: string): string {
|
|
23
|
+
const lines = source.split("\n")
|
|
24
|
+
const output: string[] = []
|
|
25
|
+
let i = 0
|
|
26
|
+
|
|
27
|
+
while (i < lines.length) {
|
|
28
|
+
const line = lines[i]
|
|
29
|
+
const openMatch = line.match(/^===\s+["']([^"']+)["']\s*$/)
|
|
30
|
+
|
|
31
|
+
if (!openMatch) {
|
|
32
|
+
output.push(line)
|
|
33
|
+
i++
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Collect all tabs in this group until the closing ===
|
|
38
|
+
const tabs: { label: string; content: string[] }[] = []
|
|
39
|
+
let currentLabel = openMatch[1]
|
|
40
|
+
let currentContent: string[] = []
|
|
41
|
+
let closed = false
|
|
42
|
+
i++
|
|
43
|
+
|
|
44
|
+
while (i < lines.length) {
|
|
45
|
+
const groupLine = lines[i]
|
|
46
|
+
const nextTabMatch = groupLine.match(/^===\s+["']([^"']+)["']\s*$/)
|
|
47
|
+
const isCloser = /^===\s*$/.test(groupLine)
|
|
48
|
+
|
|
49
|
+
if (isCloser) {
|
|
50
|
+
tabs.push({ label: currentLabel, content: currentContent })
|
|
51
|
+
closed = true
|
|
52
|
+
i++
|
|
53
|
+
break
|
|
54
|
+
} else if (nextTabMatch) {
|
|
55
|
+
tabs.push({ label: currentLabel, content: currentContent })
|
|
56
|
+
currentLabel = nextTabMatch[1]
|
|
57
|
+
currentContent = []
|
|
58
|
+
i++
|
|
59
|
+
} else {
|
|
60
|
+
currentContent.push(groupLine)
|
|
61
|
+
i++
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Handle unclosed group (EOF without ===)
|
|
66
|
+
if (!closed) {
|
|
67
|
+
tabs.push({ label: currentLabel, content: currentContent })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (tabs.length === 0) continue
|
|
71
|
+
|
|
72
|
+
output.push("<Tabs>")
|
|
73
|
+
for (const tab of tabs) {
|
|
74
|
+
output.push(`<Tab label="${tab.label}">`)
|
|
75
|
+
output.push("")
|
|
76
|
+
output.push(tab.content.join("\n").trim())
|
|
77
|
+
output.push("")
|
|
78
|
+
output.push("</Tab>")
|
|
79
|
+
}
|
|
80
|
+
output.push("</Tabs>")
|
|
81
|
+
output.push("")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return output.join("\n")
|
|
85
|
+
}
|