astro-better-docs-sidebar 0.1.0

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/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # astro-better-docs-sidebar
2
+
3
+ A documentation sidebar component for Astro that builds navigation trees from your content collection's folder structure. Supports customizable titles for pages and sidebar entries, ignorable shared content, and configurable folder behavior.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm install astro-better-docs-sidebar
9
+ ```
10
+
11
+ ## Basic usage
12
+
13
+ ```astro
14
+ ---
15
+ import { getCollection } from 'astro:content';
16
+ import Sidebar from 'astro-better-docs-sidebar/Sidebar.astro';
17
+
18
+ const collection = await getCollection('docs');
19
+ ---
20
+
21
+ <Sidebar
22
+ frontmatter={frontmatter}
23
+ collection={collection}
24
+ basePath="/docs"
25
+ />
26
+ ```
27
+
28
+ The sidebar renders into whatever element you place it in. The `aside.aside` CSS class is styled automatically if you wrap it in `<aside class="aside">`.
29
+
30
+ ## Props
31
+
32
+ | Prop | Type | Default | Description |
33
+ |------|------|---------|-------------|
34
+ | `frontmatter` | `Record<string, any>` | required | The current page's front matter. |
35
+ | `collection` | `any[]` | required | The Astro content collection to build the tree from. |
36
+ | `basePath` | `string` | required | URL prefix for all links, e.g. `"/docs"`. |
37
+ | `folderBehavior` | `'page' \| 'unclickable' \| 'overview'` | `'page'` | Default behavior for folders that have an index page. See below. |
38
+ | `overviewLabel` | `string` | `'Overview'` | Label for the synthetic overview child in `overview` mode. |
39
+
40
+ ## Folder behavior
41
+
42
+ When a folder contains an `index.mdx` (or `index.md`), the sidebar needs to decide what to do with it. The `folderBehavior` prop sets the global default, and individual folders can override it via front matter.
43
+
44
+ ### Modes
45
+
46
+ **`page`** (default) - The folder heading is a clickable link to the index page.
47
+
48
+ ```
49
+ > Core Concepts <- clickable, links to /docs/core-concepts/
50
+ What is FusionAuth
51
+ Architecture
52
+ ```
53
+
54
+ **`unclickable`** - The folder heading is a plain label. The index page is not linked anywhere in the sidebar. Use this when the index page is shared boilerplate or auto-generated content that users should not navigate to directly.
55
+
56
+ ```
57
+ Core Concepts <- not clickable, just a label
58
+ What is FusionAuth
59
+ Architecture
60
+ ```
61
+
62
+ **`overview`** - The folder heading is a plain label, but a synthetic child entry is inserted at the top of the folder linking to the index page. Use this when the index page has real content but you want the folder itself to stay unclickable.
63
+
64
+ ```
65
+ Core Concepts <- not clickable
66
+ Overview <- synthetic child, links to /docs/core-concepts/
67
+ What is FusionAuth
68
+ Architecture
69
+ ```
70
+
71
+ The `overviewLabel` prop controls the text of this synthetic child globally. It defaults to `'Overview'`.
72
+
73
+ ### Per-folder override
74
+
75
+ Set `sidebar.folderBehavior` in the front matter of a folder's index page to override the global default for that folder:
76
+
77
+ ```yaml
78
+ ---
79
+ title: Core Concepts
80
+ sidebar:
81
+ folderBehavior: overview
82
+ ---
83
+ ```
84
+
85
+ Valid values are the same three strings: `page`, `unclickable`, `overview`.
86
+
87
+ ## Customizing titles
88
+
89
+ ### Page title vs sidebar label
90
+
91
+ By default the sidebar uses the page's `title` front matter field as the link text. To show a different label in the sidebar without changing the page's `<title>`, set `sidebar.label`:
92
+
93
+ ```yaml
94
+ ---
95
+ title: Introduction to Authentication Concepts
96
+ sidebar:
97
+ label: Introduction
98
+ ---
99
+ ```
100
+
101
+ ### Folder titles
102
+
103
+ A folder's display name comes from its `index.mdx` front matter. Set `title` (or `sidebar.label`) there to control how the folder heading reads:
104
+
105
+ ```yaml
106
+ # docs/core-concepts/index.mdx
107
+ ---
108
+ title: Core Concepts
109
+ ---
110
+ ```
111
+
112
+ If no index page exists, the folder name is derived from the directory slug by splitting on hyphens and capitalizing each word (`core-concepts` becomes `Core Concepts`).
113
+
114
+ ## Hiding pages
115
+
116
+ Set `hidden: true` in a page's front matter to exclude it from the sidebar entirely:
117
+
118
+ ```yaml
119
+ ---
120
+ title: Internal Draft
121
+ hidden: true
122
+ ---
123
+ ```
124
+
125
+ ## Controlling sort order
126
+
127
+ Set `order` in front matter to control sort position within a folder. Lower numbers sort first. Pages without `order` sort last, then alphabetically by title.
128
+
129
+ ```yaml
130
+ ---
131
+ title: Getting Started
132
+ order: 1
133
+ ---
134
+ ```
135
+
136
+ ## Styling
137
+
138
+ The component uses Tailwind CSS `@apply` directives and expects Tailwind to be configured in the consuming project. All styles are injected with `<style is:global>` so they apply regardless of where the component is placed.
139
+
140
+ The sidebar renders a `<nav class="nav">` element. Wrap it in `<aside class="aside">` to get the default width, border, and background:
141
+
142
+ ```astro
143
+ <aside class="aside">
144
+ <Sidebar ... />
145
+ </aside>
146
+ ```
147
+
148
+ Dark mode is supported via the `.dark` class on a parent element.
149
+
150
+ ## SidebarItem
151
+
152
+ The `SidebarItem` component renders individual tree nodes recursively. It is exported separately if you need to build a custom tree and render nodes manually:
153
+
154
+ ```astro
155
+ import SidebarItem from 'astro-better-docs-sidebar/SidebarItem.astro';
156
+ ```
157
+
158
+ A node object passed to `SidebarItem` should have:
159
+
160
+ | Field | Type | Description |
161
+ |-------|------|-------------|
162
+ | `title` | `string` | Display text (may contain inline markdown). |
163
+ | `href` | `string \| null` | Link URL, or `null` for an unclickable label. |
164
+ | `sortedChildren` | `any[]` | Pre-sorted child nodes. |
165
+ | `isOpen` | `boolean` | Whether the `<details>` element starts open. |
166
+ | `isCurrentPage` | `boolean` | Whether to set `aria-current="page"`. |
167
+ | `isActive` | `boolean` | Whether this node is an ancestor of the current page. |
package/Sidebar.astro ADDED
@@ -0,0 +1,199 @@
1
+ ---
2
+ import SidebarItem from './SidebarItem.astro';
3
+
4
+ type FolderBehavior = 'page' | 'unclickable' | 'overview';
5
+
6
+ interface Props {
7
+ frontmatter: Record<string, any>;
8
+ collection: any[];
9
+ basePath: string;
10
+ folderBehavior?: FolderBehavior;
11
+ overviewLabel?: string;
12
+ }
13
+
14
+ const {
15
+ frontmatter,
16
+ collection,
17
+ basePath,
18
+ folderBehavior = 'page',
19
+ overviewLabel = 'Overview',
20
+ } = Astro.props;
21
+
22
+ const normalizeForMatch = (p: string | null) => {
23
+ if (!p) return "";
24
+ let clean = decodeURI(p);
25
+ if (clean.startsWith(basePath)) clean = clean.substring(basePath.length);
26
+ return clean.replace(/\.(md|mdx|html)$/i, '').replace(/\/index$/i, '').replace(/^\/+|\/+$/g, '');
27
+ };
28
+
29
+ const safeCurrentPath = normalizeForMatch(Astro.url.pathname);
30
+ const formatTitle = (str: string) => str.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
31
+
32
+ function buildTree(pages: any[]) {
33
+ const root: any = { children: {}, title: 'Documentation', href: basePath, order: -1 };
34
+ for (const page of pages) {
35
+ const { order, hidden, title: dataTitle } = page.data;
36
+ const label = page.data?.sidebar?.label;
37
+ const pageFolderBehavior: FolderBehavior | undefined = page.data?.sidebar?.folderBehavior;
38
+ if (hidden === true) continue;
39
+
40
+ const slug = page.slug || page.id || "";
41
+ const parts = slug.split('/');
42
+ let current = root;
43
+
44
+ if (slug === "index" || slug === "") {
45
+ root.title = label || dataTitle || root.title;
46
+ if (order !== undefined) root.order = order;
47
+ continue;
48
+ }
49
+
50
+ for (let i = 0; i < parts.length; i++) {
51
+ const part = parts[i];
52
+
53
+ if (part === 'index') {
54
+ const effective: FolderBehavior = pageFolderBehavior || folderBehavior;
55
+ const href = `${basePath}/${slug.replace(/\/index$/, "")}`.replace(/\/+/g, '/').replace(/\/$/, "");
56
+
57
+ current.title = label || dataTitle || current.title;
58
+ if (order !== undefined) current.order = order;
59
+
60
+ if (effective === 'page') {
61
+ current.href = href;
62
+ } else if (effective === 'overview') {
63
+ // inject a synthetic first child that links to the index page
64
+ current.children['__sidebar_overview__'] = {
65
+ name: '__sidebar_overview__',
66
+ title: overviewLabel,
67
+ href,
68
+ children: {},
69
+ sortedChildren: [],
70
+ isOpen: false,
71
+ isActive: false,
72
+ order: -Infinity,
73
+ };
74
+ }
75
+ // 'unclickable': no href set, no overview child
76
+ break;
77
+ }
78
+
79
+ if (!current.children[part]) {
80
+ current.children[part] = {
81
+ name: part,
82
+ title: formatTitle(part),
83
+ href: null,
84
+ children: {},
85
+ isOpen: false,
86
+ isActive: false,
87
+ order: Infinity,
88
+ };
89
+ }
90
+
91
+ if (i === parts.length - 1) {
92
+ current.children[part].title = label || dataTitle || formatTitle(part);
93
+ current.children[part].href = `${basePath}/${slug.replace(/\.(md|mdx)$/i, '')}`.replace(/\/+/g, '/').replace(/\/$/, "");
94
+ if (order !== undefined) current.children[part].order = order;
95
+ }
96
+ current = current.children[part];
97
+ }
98
+ }
99
+ return root;
100
+ }
101
+
102
+ const treeRoot = buildTree(collection);
103
+
104
+ function markActiveAndOpen(node: any): boolean {
105
+ node.isCurrentPage = node.href != null && normalizeForMatch(node.href) === safeCurrentPath;
106
+ let hasActiveChild = false;
107
+ for (const key in node.children) {
108
+ if (markActiveAndOpen(node.children[key])) hasActiveChild = true;
109
+ }
110
+ node.isActive = node.isCurrentPage || hasActiveChild;
111
+ node.isOpen = node.isActive;
112
+ return node.isActive;
113
+ }
114
+
115
+ markActiveAndOpen(treeRoot);
116
+
117
+ function getSortedArray(nodeChildren: Record<string, any>) {
118
+ const arr = Object.values(nodeChildren);
119
+ arr.sort((a, b) => {
120
+ const vA = a.order === undefined ? Infinity : a.order;
121
+ const vB = b.order === undefined ? Infinity : b.order;
122
+ return vA !== vB ? vA - vB : a.title.localeCompare(b.title);
123
+ });
124
+ arr.forEach(child => {
125
+ child.sortedChildren = child.children && Object.keys(child.children).length > 0 ? getSortedArray(child.children) : [];
126
+ });
127
+ return arr;
128
+ }
129
+
130
+ const treeArray = getSortedArray(treeRoot.children);
131
+ ---
132
+
133
+
134
+ <nav class="nav">
135
+ {treeArray.map(node => <SidebarItem node={node} />)}
136
+ </nav>
137
+
138
+ <style is:global>
139
+ @reference "tailwindcss";
140
+
141
+ aside.aside { @apply w-64 shrink-0 border-r border-gray-200 bg-white pt-2 pr-2; }
142
+ .nav { @apply flex flex-col gap-1; }
143
+
144
+ /* DEPTH LINE */
145
+ .nav details > div { @apply flex flex-col gap-0.5 pl-3 ml-3 border-l border-slate-200; }
146
+
147
+ /* `s` = summary wrapper container */
148
+ .s { @apply flex items-center justify-between cursor-pointer select-none rounded gap-1 transition-colors text-slate-700; list-style: none; }
149
+ .s::-webkit-details-marker { display: none; }
150
+
151
+ /* `l` = standalone leaf link OR the text link inside the summary */
152
+ .l { @apply flex-1 text-sm py-1 px-2 rounded transition-colors no-underline block text-slate-700; }
153
+ a.l { @apply cursor-pointer; }
154
+ span.l { @apply cursor-default; }
155
+
156
+ /* `c` = chevron element */
157
+ .c { @apply flex items-center justify-center w-7 h-7 rounded text-gray-400 shrink-0 transition-transform; }
158
+ .c::after { content: ''; @apply w-[0.4rem] h-[0.4rem] border-r-2 border-b-2 border-current block -rotate-45 transition-transform box-border; }
159
+ details[open] > .s .c::after { @apply rotate-45; }
160
+
161
+ /* JS-FREE FOLDER EXPANSION */
162
+ details:not([open]) > .s a.l { pointer-events: none; }
163
+
164
+ /* HOVERS: Group Hover (Leaves, Closed Folders, No-Link Folders) */
165
+ :is(a.l, details:not([open]) > .s, details:not(:has(a.l)) > .s):hover { @apply bg-slate-100; }
166
+ a.l:hover { @apply text-indigo-600; }
167
+ :is(details:not([open]) > .s, details:not(:has(a.l)) > .s):hover .l { @apply text-indigo-600; }
168
+
169
+ /* HOVERS: Separate Hovers (Open Folders WITH Links) */
170
+ details[open] > .s:has(a.l) a.l:hover { @apply bg-slate-100 text-indigo-600; }
171
+ details[open] > .s:has(a.l) .c:hover { @apply bg-slate-100 text-slate-700; }
172
+
173
+ /* ACTIVE & ANCESTOR STATES */
174
+ .l[aria-current="page"], .s:has(> .l[aria-current="page"]) .l { @apply font-semibold text-indigo-600 bg-indigo-50; }
175
+ .l[data-a], .s[data-a] .l { @apply font-semibold text-slate-900; }
176
+ .s:has(> [aria-current="page"]) .c, details:has([aria-current="page"]) > .s .c { @apply text-indigo-500; }
177
+
178
+ /* --- DARK MODE --- */
179
+ .dark aside.aside { @apply border-gray-800 bg-gray-900; }
180
+
181
+ /* Dimmed inactive text to increase contrast with bold active items */
182
+ .dark :is(.l, .s) { @apply text-slate-400; }
183
+
184
+ /* Dark mode border color for the depth line */
185
+ .dark .nav details > div { @apply border-slate-700; }
186
+
187
+ .dark :is(a.l, details:not([open]) > .s, details:not(:has(a.l)) > .s):hover { @apply bg-slate-800; }
188
+ .dark a.l:hover { @apply text-indigo-400; }
189
+ .dark :is(details:not([open]) > .s, details:not(:has(a.l)) > .s):hover .l { @apply text-indigo-400; }
190
+
191
+ .dark details[open] > .s:has(a.l) a.l:hover { @apply bg-slate-800 text-indigo-400; }
192
+ .dark details[open] > .s:has(a.l) .c:hover { @apply bg-slate-800 text-slate-200; }
193
+
194
+ .dark .l[aria-current="page"],
195
+ .dark .s:has(> .l[aria-current="page"]) .l { @apply text-indigo-400 bg-indigo-950/40; }
196
+
197
+ .dark .l[data-a],
198
+ .dark .s[data-a] .l { @apply text-white; }
199
+ </style>
@@ -0,0 +1,39 @@
1
+ ---
2
+ import { Marked } from "marked";
3
+
4
+ const { node } = Astro.props;
5
+ const childNodes = node.sortedChildren || [];
6
+
7
+ const remark = new Marked();
8
+ const htmlTitle = remark.parseInline(node.title);
9
+ const isAnc = node.isActive && !node.isCurrentPage;
10
+ ---
11
+
12
+ {childNodes.length > 0 ? (
13
+ <details open={node.isOpen || undefined}>
14
+ <summary class="s" data-a={isAnc || undefined}>
15
+ {node.href ? (
16
+ <a
17
+ href={`${node.href}/`}
18
+ class="l"
19
+ aria-current={node.isCurrentPage ? "page" : undefined}
20
+ set:html={htmlTitle}
21
+ ></a>
22
+ ) : (
23
+ <span class="l" set:html={htmlTitle}></span>
24
+ )}
25
+ <i class="c"></i>
26
+ </summary>
27
+ <div>
28
+ {childNodes.map((child: any) => <Astro.self node={child} />)}
29
+ </div>
30
+ </details>
31
+ ) : (
32
+ <a
33
+ href={node.href}
34
+ class="l"
35
+ aria-current={node.isCurrentPage ? "page" : undefined}
36
+ data-a={isAnc || undefined}
37
+ set:html={htmlTitle}
38
+ ></a>
39
+ )}
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "astro-better-docs-sidebar",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ "./Sidebar.astro": "./Sidebar.astro",
7
+ "./SidebarItem.astro": "./SidebarItem.astro"
8
+ },
9
+ "peerDependencies": {
10
+ "astro": ">=4.0.0"
11
+ },
12
+ "dependencies": {
13
+ "marked": "^17.0.0"
14
+ },
15
+ "keywords": ["astro", "sidebar", "docs", "navigation"],
16
+ "license": "MIT"
17
+ }