starlight-cannoli-plugins 0.1.0 → 1.0.1

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 CHANGED
@@ -6,13 +6,22 @@ A collection of powerful plugins for [Astro Starlight](https://starlight.astro.b
6
6
 
7
7
  ### Starlight Index-Only Sidebar
8
8
 
9
- Automatically generates your Starlight sidebar by scanning for `index.md` files in specified directories. Only directories containing an `index.md` file will appear in the sidebar.
9
+ Automatically generates a nested Starlight sidebar by recursively scanning directories for `index.md`/`index.mdx` files. Only directories with index files appear in the sidebar, creating a clean, minimal navigation structure.
10
10
 
11
11
  **Features:**
12
- - Scans directories recursively for `index.md` files
12
+ - Recursively scans directories for `index.md` or `index.mdx` files
13
+ - Creates sidebar entries only for pages with index files
13
14
  - Respects frontmatter: `draft: true` and `sidebar.hidden: true` hide entries
14
- - Option to use directory names as sidebar labels with automatic formatting (e.g., `csci-316` → `CSCI 316`)
15
- - No manual sidebar configuration needed
15
+ - Automatically collapses single-child groups (no intermediate wrappers)
16
+ - Configurable depth limiting to flatten deeply nested content
17
+ - Two labeling modes: directory names or frontmatter titles
18
+ - Ignores `assets` directories entirely
19
+
20
+ **Options:**
21
+
22
+ - `directories` (required): Array of directory names to scan (e.g., `["guides", "api"]`)
23
+ - `maxDepthNesting` (optional, default: `100`): Maximum nesting depth. Root is level 0. At max depth, deeper index files are flattened as sibling items.
24
+ - `dirnameDeterminesLabels` (optional, default: `true`): When `true`, all labels use raw directory names. When `false`, slug item labels come from frontmatter `title` field; group labels still use directory names.
16
25
 
17
26
  **Usage:**
18
27
 
@@ -28,11 +37,9 @@ export default defineConfig({
28
37
  title: "My Docs",
29
38
  plugins: [
30
39
  starlightIndexOnlySidebar({
31
- directories: [
32
- { label: "Guides", directory: "guides" },
33
- { label: "API Docs", directory: "api" },
34
- ],
35
- dirnameDeterminesLabel: false, // optional: use directory names as labels
40
+ directories: ["guides", "api", "tutorials"],
41
+ maxDepthNesting: 2, // optional
42
+ dirnameDeterminesLabels: false, // optional
36
43
  }),
37
44
  ],
38
45
  }),
@@ -50,6 +57,7 @@ A rehype plugin that validates all internal links in your Markdown/MDX files at
50
57
  - Auto-expands extensionless links to match `.md` or `.mdx` files
51
58
  - Converts internal links to site-absolute paths
52
59
  - Throws build errors for broken links
60
+ - Skip validation for forward-reference links using multiple approaches
53
61
 
54
62
  **Usage:**
55
63
 
@@ -73,6 +81,48 @@ Or import directly:
73
81
  import { rehypeValidateLinks } from "cannoli-starlight-plugins/rehype-validate-links";
74
82
  ```
75
83
 
84
+ **Skipping Link Validation:**
85
+
86
+ There are three ways to skip validation for specific links:
87
+
88
+ **1. Question Mark Prefix** (Per-link, in markdown)
89
+
90
+ Prepend a `?` to the link href to skip validation:
91
+
92
+ ```mdx
93
+ [Grade Calculator](?csci-320-331-obrenic/grade-calculator)
94
+ [Grade Calculator](?./csci-320-331-obrenic/grade-calculator)
95
+ [Grade Calculator](?/csci-320-331-obrenic/grade-calculator)
96
+ ```
97
+
98
+ **2. HTML Data Attribute** (Per-link, requires HTML syntax)
99
+
100
+ Use the `data-no-link-check` attribute on anchor tags:
101
+
102
+ ```mdx
103
+ <a href="csci-320-331-obrenic/grade-calculator" data-no-link-check>Grade Calculator</a>
104
+ ```
105
+
106
+ **3. Global Skip Patterns** (Configuration-based)
107
+
108
+ Use the `skipPatterns` option to exclude links matching glob patterns:
109
+
110
+ ```ts
111
+ // astro.config.mjs
112
+ export default defineConfig({
113
+ markdown: {
114
+ rehypePlugins: [
115
+ [rehypeValidateLinks, {
116
+ skipPatterns: [
117
+ '/csci-320-331-obrenic/grade-calculator', // exact match
118
+ '**/draft-*', // glob pattern
119
+ ]
120
+ }],
121
+ ],
122
+ },
123
+ });
124
+ ```
125
+
76
126
  ## Installation
77
127
 
78
128
  ```bash
@@ -0,0 +1,246 @@
1
+ // src/plugins/starlight-index-only-sidebar.ts
2
+ import { join as join2 } from "path";
3
+
4
+ // src/plugins/utils/sidebar-builder-utils.ts
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+ import { parse as parseYaml } from "yaml";
8
+ function isDir(filePath) {
9
+ try {
10
+ return fs.statSync(filePath).isDirectory();
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+ function findIndexMd(dirPath) {
16
+ const indexMd = path.join(dirPath, "index.md");
17
+ const indexMdx = path.join(dirPath, "index.mdx");
18
+ try {
19
+ if (fs.statSync(indexMd).isFile()) return indexMd;
20
+ if (fs.statSync(indexMdx).isFile()) return indexMdx;
21
+ } catch {
22
+ }
23
+ return null;
24
+ }
25
+ function parseFrontmatter(filePath) {
26
+ try {
27
+ const content = fs.readFileSync(filePath, "utf-8");
28
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
29
+ if (!match) return {};
30
+ const frontmatter = parseYaml(match[1]);
31
+ return typeof frontmatter === "object" && frontmatter ? frontmatter : {};
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+ function getSlug(dirPath, rootDir) {
37
+ const normalized = path.normalize(dirPath).replace(/\\/g, "/");
38
+ const rootParent = path.normalize(path.dirname(rootDir)).replace(/\\/g, "/");
39
+ const slug = normalized.startsWith(rootParent) ? normalized.slice(rootParent.length + 1) : normalized;
40
+ return slug + "/index";
41
+ }
42
+ function hasIndexMdInSubtree(dirPath) {
43
+ const indexFile = findIndexMd(dirPath);
44
+ if (indexFile) {
45
+ const fm = parseFrontmatter(indexFile);
46
+ if (fm.draft || fm.sidebar?.hidden) return false;
47
+ return true;
48
+ }
49
+ try {
50
+ const entries = fs.readdirSync(dirPath);
51
+ for (const entry of entries) {
52
+ if (entry === "assets") continue;
53
+ const fullPath = path.join(dirPath, entry);
54
+ if (isDir(fullPath) && hasIndexMdInSubtree(fullPath)) {
55
+ return true;
56
+ }
57
+ }
58
+ } catch {
59
+ return false;
60
+ }
61
+ return false;
62
+ }
63
+ function isGroupItem(item) {
64
+ return "items" in item;
65
+ }
66
+ function pathSegmentToLabel(pathSegment) {
67
+ let label = pathSegment.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
68
+ const match = label.match(/^([a-z]+) [0-9]{2,}/i);
69
+ if (match) {
70
+ const firstGroup = match[1];
71
+ const uppercasedFirstGroup = firstGroup.toUpperCase();
72
+ label = uppercasedFirstGroup + label.substring(firstGroup.length);
73
+ }
74
+ return label;
75
+ }
76
+ function buildSidebarItems(dirPath, rootDir, currentDepth, maxDepth, dirnameDeterminesLabels, parentDirName = "") {
77
+ const items = [];
78
+ const indexFile = findIndexMd(dirPath);
79
+ if (indexFile) {
80
+ const fm = parseFrontmatter(indexFile);
81
+ if (!fm.draft && !fm.sidebar?.hidden) {
82
+ let label;
83
+ if (currentDepth >= maxDepth && parentDirName) {
84
+ label = parentDirName;
85
+ } else if (dirnameDeterminesLabels) {
86
+ label = "Overview";
87
+ } else {
88
+ label = fm.title || "Overview";
89
+ }
90
+ items.push({
91
+ label,
92
+ slug: getSlug(dirPath, rootDir)
93
+ });
94
+ }
95
+ }
96
+ if (currentDepth >= maxDepth) {
97
+ const flattenedItems = flattenIndexFilesAtDepth(
98
+ dirPath,
99
+ rootDir,
100
+ dirnameDeterminesLabels
101
+ );
102
+ items.push(...flattenedItems);
103
+ return items;
104
+ }
105
+ try {
106
+ const entries = fs.readdirSync(dirPath).sort();
107
+ for (const entry of entries) {
108
+ if (entry === "assets") continue;
109
+ const fullPath = path.join(dirPath, entry);
110
+ if (!isDir(fullPath)) continue;
111
+ if (!hasIndexMdInSubtree(fullPath)) continue;
112
+ const subItems = buildSidebarItems(
113
+ fullPath,
114
+ rootDir,
115
+ currentDepth + 1,
116
+ maxDepth,
117
+ dirnameDeterminesLabels,
118
+ entry
119
+ );
120
+ const hasIndex = findIndexMd(fullPath) !== null;
121
+ if (!hasIndex) {
122
+ const allGroups = subItems.length > 0 && subItems.every(isGroupItem);
123
+ if (allGroups || subItems.length === 1 && isGroupItem(subItems[0])) {
124
+ items.push(...subItems);
125
+ } else {
126
+ items.push({
127
+ label: pathSegmentToLabel(entry),
128
+ items: subItems
129
+ });
130
+ }
131
+ } else {
132
+ items.push({
133
+ label: pathSegmentToLabel(entry),
134
+ items: subItems
135
+ });
136
+ }
137
+ }
138
+ } catch {
139
+ }
140
+ return items;
141
+ }
142
+ function flattenIndexFilesAtDepth(dirPath, rootDir, dirnameDeterminesLabels) {
143
+ const items = [];
144
+ try {
145
+ const entries = fs.readdirSync(dirPath);
146
+ for (const entry of entries) {
147
+ if (entry === "assets") continue;
148
+ const fullPath = path.join(dirPath, entry);
149
+ if (!isDir(fullPath)) continue;
150
+ const indexFile = findIndexMd(fullPath);
151
+ if (indexFile) {
152
+ const fm = parseFrontmatter(indexFile);
153
+ if (!fm.draft && !fm.sidebar?.hidden) {
154
+ items.push({
155
+ label: pathSegmentToLabel(entry),
156
+ slug: getSlug(fullPath, rootDir)
157
+ });
158
+ }
159
+ }
160
+ items.push(
161
+ ...flattenIndexFilesAtDepth(fullPath, rootDir, dirnameDeterminesLabels)
162
+ );
163
+ }
164
+ } catch {
165
+ }
166
+ return items;
167
+ }
168
+ function getIndexMdSidebarItems(directory, options = {}) {
169
+ const maxDepth = options.maxDepthNesting ?? 100;
170
+ const dirnameDeterminesLabels = options.dirnameDeterminesLabels ?? true;
171
+ const rootDirName = pathSegmentToLabel(path.basename(directory));
172
+ const items = buildSidebarItems(
173
+ directory,
174
+ directory,
175
+ 0,
176
+ maxDepth,
177
+ dirnameDeterminesLabels
178
+ );
179
+ return [
180
+ {
181
+ label: rootDirName,
182
+ items
183
+ }
184
+ ];
185
+ }
186
+
187
+ // src/plugins/starlight-index-only-sidebar.ts
188
+ var SITE_DOCS_ROOT = "./src/content/docs";
189
+ function normalizeSlug(slug) {
190
+ return slug.endsWith("/index") ? slug.slice(0, -6) : slug;
191
+ }
192
+ function normalizeItems(items) {
193
+ return items.map((item) => {
194
+ if ("items" in item) {
195
+ return {
196
+ label: item.label,
197
+ items: normalizeItems(item.items)
198
+ };
199
+ }
200
+ if ("slug" in item) {
201
+ return {
202
+ label: item.label,
203
+ slug: normalizeSlug(item.slug)
204
+ };
205
+ }
206
+ return item;
207
+ });
208
+ }
209
+ function starlightIndexOnlySidebar(pluginOptions) {
210
+ return {
211
+ name: "index-only-sidebar",
212
+ hooks: {
213
+ "config:setup": (hookOptions) => {
214
+ const { updateConfig } = hookOptions;
215
+ const { directories, maxDepthNesting, dirnameDeterminesLabels } = pluginOptions;
216
+ const functionOptions = {
217
+ maxDepthNesting,
218
+ dirnameDeterminesLabels
219
+ };
220
+ const sidebarItems = directories.map((directory) => {
221
+ const dirPath = join2(SITE_DOCS_ROOT, directory);
222
+ const rawItems = getIndexMdSidebarItems(dirPath, functionOptions);
223
+ const rootGroup = rawItems[0];
224
+ if (!rootGroup || !("items" in rootGroup)) {
225
+ return void 0;
226
+ }
227
+ const items = rootGroup.items || [];
228
+ const normalizedItems = normalizeItems(items);
229
+ return {
230
+ label: rootGroup.label,
231
+ items: normalizedItems
232
+ };
233
+ }).filter(
234
+ (group) => group !== void 0 && group.items.length > 0
235
+ );
236
+ updateConfig({
237
+ sidebar: sidebarItems
238
+ });
239
+ }
240
+ }
241
+ };
242
+ }
243
+
244
+ export {
245
+ starlightIndexOnlySidebar
246
+ };
@@ -1,19 +1,32 @@
1
1
  // src/plugins/rehype-validate-links.ts
2
2
  import { existsSync } from "fs";
3
3
  import { sync as globSync } from "glob";
4
+ import { minimatch } from "minimatch";
4
5
  import { dirname, join, relative, resolve } from "path";
5
6
  import { visit } from "unist-util-visit";
6
7
  var PROJECT_DOCS_DIR = "src/content/docs";
8
+ function matchesSkipPattern(path, patterns) {
9
+ if (!patterns || patterns.length === 0) {
10
+ return false;
11
+ }
12
+ return patterns.some((pattern) => minimatch(path, pattern));
13
+ }
7
14
  function getResolvedLink(href, currentFilePath) {
15
+ let skipValidation = false;
16
+ let processedHref = href;
17
+ if (href.startsWith("?")) {
18
+ skipValidation = true;
19
+ processedHref = href.slice(1);
20
+ }
8
21
  try {
9
- new URL(href);
22
+ new URL(processedHref);
10
23
  return null;
11
24
  } catch {
12
25
  }
13
- if (!href) {
26
+ if (!processedHref) {
14
27
  return null;
15
28
  }
16
- const fragmentMatch = href.split("#");
29
+ const fragmentMatch = processedHref.split("#");
17
30
  const withoutFragment = fragmentMatch[0];
18
31
  const fragment = fragmentMatch[1] || "";
19
32
  if (!withoutFragment) {
@@ -46,7 +59,8 @@ function getResolvedLink(href, currentFilePath) {
46
59
  original_href: href,
47
60
  project_absolute_href: finalProjectAbsoluteHref,
48
61
  site_absolute_href: siteAbsoluteHref,
49
- fragment
62
+ fragment,
63
+ skipValidation
50
64
  };
51
65
  }
52
66
  function validateLink(link) {
@@ -73,7 +87,7 @@ function validateLink(link) {
73
87
  }
74
88
  }
75
89
  }
76
- function rehypeValidateLinks() {
90
+ function rehypeValidateLinks(options) {
77
91
  return (tree, file) => {
78
92
  const filePath = file.path;
79
93
  if (!filePath) {
@@ -82,7 +96,7 @@ function rehypeValidateLinks() {
82
96
  );
83
97
  return;
84
98
  }
85
- visit(tree, "element", (node) => {
99
+ visit(tree, "element", (node, index, parent) => {
86
100
  let resourcePath;
87
101
  let attributeName = null;
88
102
  if (node.tagName === "a") {
@@ -95,6 +109,29 @@ function rehypeValidateLinks() {
95
109
  if (!resourcePath || !attributeName) return;
96
110
  const link = getResolvedLink(resourcePath, filePath);
97
111
  if (!link) return;
112
+ if (link.skipValidation) {
113
+ node.properties = node.properties || {};
114
+ node.properties[attributeName] = link.site_absolute_href;
115
+ return;
116
+ }
117
+ if (node.properties?.["data-no-link-check"] !== void 0) {
118
+ node.properties = node.properties || {};
119
+ node.properties[attributeName] = link.site_absolute_href;
120
+ return;
121
+ }
122
+ if (matchesSkipPattern(link.site_absolute_href, options?.skipPatterns)) {
123
+ node.properties = node.properties || {};
124
+ node.properties[attributeName] = link.site_absolute_href;
125
+ return;
126
+ }
127
+ if (index !== void 0 && parent && "children" in parent && Array.isArray(parent.children)) {
128
+ const nextNode = parent.children[index + 1];
129
+ if (nextNode && "type" in nextNode && nextNode.type === "comment" && "value" in nextNode && typeof nextNode.value === "string" && nextNode.value.includes("no-link-check")) {
130
+ node.properties = node.properties || {};
131
+ node.properties[attributeName] = link.site_absolute_href;
132
+ return;
133
+ }
134
+ }
98
135
  validateLink(link);
99
136
  node.properties = node.properties || {};
100
137
  node.properties[attributeName] = link.site_absolute_href;
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  starlightIndexOnlySidebar
3
- } from "./chunk-6XXMT37V.js";
3
+ } from "./chunk-NTIYGHZG.js";
4
4
  import {
5
5
  rehypeValidateLinks
6
- } from "./chunk-WOOF7XZX.js";
6
+ } from "./chunk-TXRBCETT.js";
7
7
  export {
8
8
  rehypeValidateLinks,
9
9
  starlightIndexOnlySidebar
@@ -1,9 +1,12 @@
1
1
  import { Root } from 'hast';
2
2
  import { VFile } from 'vfile';
3
3
 
4
+ type TRehypeValidateLinksOptions = {
5
+ skipPatterns?: string[];
6
+ };
4
7
  /**
5
8
  * Rehype plugin to validate all internal links and convert them to absolute paths
6
9
  */
7
- declare function rehypeValidateLinks(): (tree: Root, file: VFile) => void;
10
+ declare function rehypeValidateLinks(options?: TRehypeValidateLinksOptions): (tree: Root, file: VFile) => void;
8
11
 
9
12
  export { rehypeValidateLinks as default, rehypeValidateLinks };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  rehypeValidateLinks,
3
3
  rehype_validate_links_default
4
- } from "../chunk-WOOF7XZX.js";
4
+ } from "../chunk-TXRBCETT.js";
5
5
  export {
6
6
  rehype_validate_links_default as default,
7
7
  rehypeValidateLinks
@@ -1,14 +1,13 @@
1
1
  import { HookParameters } from '@astrojs/starlight/types';
2
2
 
3
- type TDirectoryConfig = {
4
- label: string;
5
- directory: string;
3
+ type TOptions = {
4
+ maxDepthNesting?: number;
5
+ dirnameDeterminesLabels?: boolean;
6
6
  };
7
- type TIndexOnlySidebarPluginOptions = {
8
- directories: Array<TDirectoryConfig>;
9
- dirnameDeterminesLabel?: boolean;
7
+ type TIndexOnlySidebarPluginOptions = TOptions & {
8
+ directories: string[];
10
9
  };
11
- declare function starlightIndexOnlySidebar(options: TIndexOnlySidebarPluginOptions): {
10
+ declare function starlightIndexOnlySidebar(pluginOptions: TIndexOnlySidebarPluginOptions): {
12
11
  name: string;
13
12
  hooks: {
14
13
  "config:setup": (hookOptions: HookParameters<"config:setup">) => void;
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  starlightIndexOnlySidebar
3
- } from "../chunk-6XXMT37V.js";
3
+ } from "../chunk-NTIYGHZG.js";
4
4
  export {
5
5
  starlightIndexOnlySidebar
6
6
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "starlight-cannoli-plugins",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "1.0.1",
5
5
  "description": "Starlight plugins for automatic sidebar generation and link validation",
6
6
  "license": "ISC",
7
7
  "main": "./dist/index.js",
@@ -36,16 +36,20 @@
36
36
  "url": "https://github.com/OccasionalCoderByTrade/astro-starlight-plugins"
37
37
  },
38
38
  "scripts": {
39
- "build:lib": "tsup",
40
- "dev": "node scripts/sync-docs-to-public.js && astro dev --port 5600",
41
- "build": "npm run build:lib && node scripts/sync-docs-to-public.js && astro build",
42
- "preview": "astro build && astro preview --port 5600",
43
- "help": "astro --help",
44
- "check": "astro check",
45
- "prepublishOnly": "npm run build:lib"
39
+ "app:dev": "node scripts/sync-docs-to-public.js && rm -rf .astro/ && astro dev --port 5600",
40
+ "app:build": "rm -rf .astro/ && node scripts/sync-docs-to-public.js && astro build",
41
+ "app:preview": "astro build && astro preview --port 5600",
42
+ "app:help": "astro --help",
43
+ "app:check": "astro check",
44
+ "lib:build": "tsup",
45
+ "prepublishOnly": "npm run lib:build",
46
+ "tree:index": "tree -P index.md src/content/docs",
47
+ "tree:depth": "bash scripts/tree-depth.sh",
48
+ "clean:empty-dirs": "find src/content/docs -type d -empty -delete"
46
49
  },
47
50
  "dependencies": {
48
51
  "glob": "^13.0.6",
52
+ "minimatch": "^10.2.4",
49
53
  "unist-util-visit": "^5.0.0",
50
54
  "yaml": "^2.4.0"
51
55
  },
@@ -64,29 +68,31 @@
64
68
  "devDependencies": {
65
69
  "@astrojs/check": "^0.9.6",
66
70
  "@astrojs/starlight": "^0.37.6",
71
+ "@eslint/js": "^10.0.1",
72
+ "@eslint/markdown": "^7.5.1",
67
73
  "@expressive-code/plugin-line-numbers": "^0.41.7",
68
74
  "@hpcc-js/wasm": "^2.33.1",
75
+ "@trivago/prettier-plugin-sort-imports": "^6.0.2",
69
76
  "@types/hast": "^2.3.10",
77
+ "@typescript-eslint/eslint-plugin": "^8.56.1",
78
+ "@typescript-eslint/parser": "^8.56.1",
70
79
  "astro": "^5.6.1",
80
+ "eslint": "^10.0.2",
81
+ "eslint-md-cannoli-plugins": "^1.0.2",
82
+ "globals": "^17.4.0",
71
83
  "hast": "^1.0.0",
84
+ "jiti": "^2.6.1",
85
+ "lodash": "^4.17.23",
72
86
  "rehype-graphviz": "^0.3.0",
73
87
  "rehype-mathjax": "^7.1.0",
74
88
  "rehype-raw": "^7.0.0",
75
89
  "remark-math": "^6.0.0",
90
+ "sass-embedded": "^1.97.3",
76
91
  "sharp": "^0.34.2",
77
92
  "tsup": "^8.3.0",
93
+ "tsx": "^4.21.0",
78
94
  "typescript": "^5.9.3",
79
- "vfile": "^6.0.0",
80
- "@eslint/js": "^10.0.1",
81
- "@eslint/markdown": "^7.5.1",
82
- "@trivago/prettier-plugin-sort-imports": "^6.0.2",
83
- "@typescript-eslint/eslint-plugin": "^8.56.1",
84
- "@typescript-eslint/parser": "^8.56.1",
85
- "eslint": "^10.0.2",
86
- "eslint-md-cannoli-plugins": "^1.0.2",
87
- "globals": "^17.4.0",
88
- "jiti": "^2.6.1",
89
- "sass-embedded": "^1.97.3",
90
- "typescript-eslint": "^8.56.1"
95
+ "typescript-eslint": "^8.56.1",
96
+ "vfile": "^6.0.0"
91
97
  }
92
98
  }
@@ -1,162 +0,0 @@
1
- // src/plugins/starlight-index-only-sidebar.ts
2
- import { readFileSync, readdirSync, statSync } from "fs";
3
- import { join } from "path";
4
- import { parse as parseYaml } from "yaml";
5
- var SITE_DOCS_ROOT = "./src/content/docs";
6
- function getFrontmatterMetadata(mdFilePath) {
7
- const defaultMetadata = {
8
- title: null,
9
- draft: false,
10
- sidebarHidden: false
11
- };
12
- try {
13
- const content = readFileSync(mdFilePath, "utf-8");
14
- const match = content.match(/^---\n([\s\S]*?)\n---/);
15
- if (!match) return defaultMetadata;
16
- const frontmatterText = match[1];
17
- const frontmatter = parseYaml(frontmatterText);
18
- if (!frontmatter || typeof frontmatter !== "object") {
19
- return defaultMetadata;
20
- }
21
- const title = typeof frontmatter.title === "string" ? frontmatter.title : null;
22
- const draft = frontmatter.draft === true;
23
- let sidebarHidden = false;
24
- if (frontmatter.sidebar) {
25
- if (typeof frontmatter.sidebar === "object" && frontmatter.sidebar !== null) {
26
- sidebarHidden = frontmatter.sidebar.hidden === true;
27
- }
28
- }
29
- return {
30
- title,
31
- draft,
32
- sidebarHidden
33
- };
34
- } catch {
35
- return defaultMetadata;
36
- }
37
- }
38
- function pathSegmentToLabel(pathSegment) {
39
- let label = pathSegment.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
40
- const match = label.match(/^([a-z]+) [0-9]{2,}/i);
41
- if (match) {
42
- const firstGroup = match[1];
43
- const uppercasedFirstGroup = firstGroup.toUpperCase();
44
- label = uppercasedFirstGroup + label.substring(firstGroup.length);
45
- }
46
- return label;
47
- }
48
- function processRootIndex(dir, basePath, dirDeterminesLabel, items) {
49
- if (!basePath || basePath.includes("/")) {
50
- return;
51
- }
52
- const indexPath = join(dir, "index.md");
53
- try {
54
- statSync(indexPath);
55
- } catch {
56
- return;
57
- }
58
- const metadata = getFrontmatterMetadata(indexPath);
59
- if (metadata.draft || metadata.sidebarHidden) {
60
- return;
61
- }
62
- if (dirDeterminesLabel) {
63
- const lastSegment = basePath.split("/").pop() || basePath;
64
- items.push({
65
- label: pathSegmentToLabel(lastSegment),
66
- slug: basePath
67
- });
68
- } else {
69
- items.push(basePath);
70
- }
71
- }
72
- function buildIndexSidebar(dir, basePath = "", docsRoot, dirDeterminesLabel = false) {
73
- const items = [];
74
- try {
75
- processRootIndex(dir, basePath, dirDeterminesLabel, items);
76
- const entries = readdirSync(dir).sort();
77
- for (const entry of entries) {
78
- if (entry.startsWith(".")) continue;
79
- const fullPath = join(dir, entry);
80
- const stat = statSync(fullPath);
81
- if (stat.isDirectory()) {
82
- const indexPath = join(fullPath, "index.md");
83
- const slug = basePath ? `${basePath}/${entry}` : entry;
84
- try {
85
- statSync(indexPath);
86
- const metadata = getFrontmatterMetadata(indexPath);
87
- if (metadata.draft || metadata.sidebarHidden) {
88
- continue;
89
- }
90
- const subItems = buildIndexSidebar(fullPath, slug, docsRoot, dirDeterminesLabel);
91
- if (subItems.length > 0) {
92
- let groupLabel;
93
- if (dirDeterminesLabel) {
94
- groupLabel = pathSegmentToLabel(entry);
95
- } else {
96
- const title = metadata.title || entry;
97
- groupLabel = title;
98
- }
99
- items.push({
100
- label: groupLabel,
101
- items: [dirDeterminesLabel ? { label: groupLabel, slug } : slug, ...subItems]
102
- });
103
- } else {
104
- if (dirDeterminesLabel) {
105
- items.push({
106
- label: pathSegmentToLabel(entry),
107
- slug
108
- });
109
- } else {
110
- items.push(slug);
111
- }
112
- }
113
- } catch {
114
- const subItems = buildIndexSidebar(fullPath, slug, docsRoot, dirDeterminesLabel);
115
- if (subItems.length > 0) {
116
- items.push(...subItems);
117
- }
118
- }
119
- }
120
- }
121
- } catch (error) {
122
- console.error(`Error reading directory ${dir}:`, error);
123
- }
124
- return items.sort((a, b) => {
125
- const aIsGroup = typeof a === "object" && "items" in a;
126
- const bIsGroup = typeof b === "object" && "items" in b;
127
- if (!aIsGroup && bIsGroup) return -1;
128
- if (aIsGroup && !bIsGroup) return 1;
129
- return 0;
130
- });
131
- }
132
- function starlightIndexOnlySidebar(options) {
133
- return {
134
- name: "index-only-sidebar",
135
- hooks: {
136
- "config:setup": (hookOptions) => {
137
- const { updateConfig } = hookOptions;
138
- const dirnameDeterminesLabel = options.dirnameDeterminesLabel ?? false;
139
- const sidebarItems = options.directories.map((dirConfig) => {
140
- const dirItems = buildIndexSidebar(
141
- join(SITE_DOCS_ROOT, dirConfig.directory),
142
- dirConfig.directory,
143
- SITE_DOCS_ROOT,
144
- dirnameDeterminesLabel
145
- );
146
- const label = dirnameDeterminesLabel ? pathSegmentToLabel(dirConfig.directory) : dirConfig.label;
147
- return {
148
- label,
149
- items: dirItems
150
- };
151
- }).filter((group) => group.items.length > 0);
152
- updateConfig({
153
- sidebar: sidebarItems
154
- });
155
- }
156
- }
157
- };
158
- }
159
-
160
- export {
161
- starlightIndexOnlySidebar
162
- };