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 +167 -0
- package/Sidebar.astro +199 -0
- package/SidebarItem.astro +39 -0
- package/package.json +17 -0
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
|
+
}
|