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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camelmind",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Create and manage CamelMind documentation sites",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- <li>
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 pl-4 pr-3 text-sm border-l-2 transition-colors ${
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
- : "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"
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
- </li>
51
+ </div>
36
52
  )
37
53
  }
38
54
 
39
- // Collapsible section row (e.g. "DoW Deployment") collapsed by default
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 only if the current page lives inside this section
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
- className={`block py-1.5 pl-4 pr-2 text-sm border-l-2 transition-colors ${
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
- : "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"
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 — full-width click area toggles collapse */}
83
- <button
84
- onClick={() => setOpen((o) => !o)}
85
- className="w-full flex items-center justify-between py-1.5 px-2 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors rounded"
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
- <span>{section.label}</span>
88
- <svg
89
- className={`w-3.5 h-3.5 shrink-0 text-gray-400 transition-transform ${open ? "rotate-0" : "rotate-180"}`}
90
- fill="none"
91
- viewBox="0 0 24 24"
92
- stroke="currentColor"
93
- strokeWidth={2.5}
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
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
96
- </svg>
97
- </button>
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
- <ul className="mt-0.5 mb-1">
101
- {docs.map((doc) => (
102
- <DocLink key={doc.slug} item={doc} currentSlug={currentSlug} />
103
- ))}
104
- </ul>
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 overflow-hidden"
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="text-sm font-medium text-gray-800">{summary}</span>
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
- const [active, setActive] = useState(0)
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 not-prose">
28
- {/* Screen: tabbed UI hidden in PDF via data-print="hide" */}
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
+ }