starlight-cannoli-plugins 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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # Cannoli Starlight Plugins
2
+
3
+ A collection of powerful plugins for [Astro Starlight](https://starlight.astro.build/) documentation sites.
4
+
5
+ ## Plugins
6
+
7
+ ### Starlight Index-Only Sidebar
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.
10
+
11
+ **Features:**
12
+ - Scans directories recursively for `index.md` files
13
+ - 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
16
+
17
+ **Usage:**
18
+
19
+ ```ts
20
+ // astro.config.mjs
21
+ import { defineConfig } from "astro/config";
22
+ import starlight from "@astrojs/starlight";
23
+ import { starlightIndexOnlySidebar } from "cannoli-starlight-plugins";
24
+
25
+ export default defineConfig({
26
+ integrations: [
27
+ starlight({
28
+ title: "My Docs",
29
+ plugins: [
30
+ starlightIndexOnlySidebar({
31
+ directories: [
32
+ { label: "Guides", directory: "guides" },
33
+ { label: "API Docs", directory: "api" },
34
+ ],
35
+ dirnameDeterminesLabel: false, // optional: use directory names as labels
36
+ }),
37
+ ],
38
+ }),
39
+ ],
40
+ });
41
+ ```
42
+
43
+ ### Rehype Validate Links
44
+
45
+ A rehype plugin that validates all internal links in your Markdown/MDX files at build time. Links without matching files will cause the build to fail.
46
+
47
+ **Features:**
48
+ - Validates `<a href>` and `<img src>` attributes
49
+ - Supports relative paths (`../other`) and absolute paths (`/some/page`)
50
+ - Auto-expands extensionless links to match `.md` or `.mdx` files
51
+ - Converts internal links to site-absolute paths
52
+ - Throws build errors for broken links
53
+
54
+ **Usage:**
55
+
56
+ ```ts
57
+ // astro.config.mjs
58
+ import { defineConfig } from "astro/config";
59
+ import { rehypeValidateLinks } from "cannoli-starlight-plugins";
60
+
61
+ export default defineConfig({
62
+ markdown: {
63
+ rehypePlugins: [
64
+ rehypeValidateLinks,
65
+ ],
66
+ },
67
+ });
68
+ ```
69
+
70
+ Or import directly:
71
+
72
+ ```ts
73
+ import { rehypeValidateLinks } from "cannoli-starlight-plugins/rehype-validate-links";
74
+ ```
75
+
76
+ ## Installation
77
+
78
+ ```bash
79
+ npm install cannoli-starlight-plugins
80
+ ```
81
+
82
+ With pnpm:
83
+
84
+ ```bash
85
+ pnpm add cannoli-starlight-plugins
86
+ ```
87
+
88
+ With yarn:
89
+
90
+ ```bash
91
+ yarn add cannoli-starlight-plugins
92
+ ```
93
+
94
+ ## Peer Dependencies
95
+
96
+ - `astro` ≥ 5.0.0
97
+ - `@astrojs/starlight` ≥ 0.30.0 (optional, only needed if using `starlightIndexOnlySidebar`)
98
+
99
+ ## License
100
+
101
+ MIT
102
+
103
+ ## Contributing
104
+
105
+ Contributions welcome! Feel free to open issues or submit pull requests.
@@ -0,0 +1,162 @@
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
+ };
@@ -0,0 +1,109 @@
1
+ // src/plugins/rehype-validate-links.ts
2
+ import { existsSync } from "fs";
3
+ import { sync as globSync } from "glob";
4
+ import { dirname, join, relative, resolve } from "path";
5
+ import { visit } from "unist-util-visit";
6
+ var PROJECT_DOCS_DIR = "src/content/docs";
7
+ function getResolvedLink(href, currentFilePath) {
8
+ try {
9
+ new URL(href);
10
+ return null;
11
+ } catch {
12
+ }
13
+ if (!href) {
14
+ return null;
15
+ }
16
+ const fragmentMatch = href.split("#");
17
+ const withoutFragment = fragmentMatch[0];
18
+ const fragment = fragmentMatch[1] || "";
19
+ if (!withoutFragment) {
20
+ return null;
21
+ }
22
+ let projectAbsolute;
23
+ let relativePath;
24
+ if (withoutFragment.startsWith("/")) {
25
+ relativePath = withoutFragment.slice(1);
26
+ projectAbsolute = join(PROJECT_DOCS_DIR, relativePath);
27
+ } else {
28
+ const currentFileDir = dirname(currentFilePath);
29
+ const resolvedAbsPath = resolve(currentFileDir, withoutFragment);
30
+ projectAbsolute = resolvedAbsPath;
31
+ const docsRootAbsolute = resolve(PROJECT_DOCS_DIR);
32
+ relativePath = relative(docsRootAbsolute, resolvedAbsPath);
33
+ }
34
+ const hasExtension = /\.[a-z0-9]+$/i.test(projectAbsolute);
35
+ let finalProjectAbsoluteHref;
36
+ if (!hasExtension) {
37
+ finalProjectAbsoluteHref = `${projectAbsolute}.{md,mdx}`;
38
+ } else {
39
+ finalProjectAbsoluteHref = projectAbsolute;
40
+ }
41
+ let siteAbsoluteHref = "/" + relativePath;
42
+ if (fragment) {
43
+ siteAbsoluteHref += "#" + fragment;
44
+ }
45
+ return {
46
+ original_href: href,
47
+ project_absolute_href: finalProjectAbsoluteHref,
48
+ site_absolute_href: siteAbsoluteHref,
49
+ fragment
50
+ };
51
+ }
52
+ function validateLink(link) {
53
+ const { project_absolute_href } = link;
54
+ if (project_absolute_href.includes(".{md,mdx}")) {
55
+ const matches = globSync(project_absolute_href);
56
+ if (matches.length === 0) {
57
+ throw new Error(
58
+ `Link validation error: No matching file found for: ${link.original_href} (pattern: ${project_absolute_href})`
59
+ );
60
+ }
61
+ if (matches.length > 1) {
62
+ throw new Error(
63
+ `Link validation error: Multiple matching files found: ${matches.join(
64
+ ", "
65
+ )} (from link: ${link.original_href})`
66
+ );
67
+ }
68
+ } else {
69
+ if (!existsSync(project_absolute_href)) {
70
+ throw new Error(
71
+ `Link validation error: File not found: ${project_absolute_href} (from link: ${link.original_href})`
72
+ );
73
+ }
74
+ }
75
+ }
76
+ function rehypeValidateLinks() {
77
+ return (tree, file) => {
78
+ const filePath = file.path;
79
+ if (!filePath) {
80
+ console.warn(
81
+ "rehype-validate-links: Unable to determine file path for link validation. Skipping link validation for this file."
82
+ );
83
+ return;
84
+ }
85
+ visit(tree, "element", (node) => {
86
+ let resourcePath;
87
+ let attributeName = null;
88
+ if (node.tagName === "a") {
89
+ resourcePath = node.properties?.href;
90
+ attributeName = "href";
91
+ } else if (node.tagName === "img") {
92
+ resourcePath = node.properties?.src;
93
+ attributeName = "src";
94
+ }
95
+ if (!resourcePath || !attributeName) return;
96
+ const link = getResolvedLink(resourcePath, filePath);
97
+ if (!link) return;
98
+ validateLink(link);
99
+ node.properties = node.properties || {};
100
+ node.properties[attributeName] = link.site_absolute_href;
101
+ });
102
+ };
103
+ }
104
+ var rehype_validate_links_default = rehypeValidateLinks;
105
+
106
+ export {
107
+ rehypeValidateLinks,
108
+ rehype_validate_links_default
109
+ };
@@ -0,0 +1,5 @@
1
+ export { starlightIndexOnlySidebar } from './plugins/starlight-index-only-sidebar.js';
2
+ export { default as rehypeValidateLinks } from './plugins/rehype-validate-links.js';
3
+ import '@astrojs/starlight/types';
4
+ import 'hast';
5
+ import 'vfile';
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ import {
2
+ starlightIndexOnlySidebar
3
+ } from "./chunk-6XXMT37V.js";
4
+ import {
5
+ rehypeValidateLinks
6
+ } from "./chunk-WOOF7XZX.js";
7
+ export {
8
+ rehypeValidateLinks,
9
+ starlightIndexOnlySidebar
10
+ };
@@ -0,0 +1,9 @@
1
+ import { Root } from 'hast';
2
+ import { VFile } from 'vfile';
3
+
4
+ /**
5
+ * Rehype plugin to validate all internal links and convert them to absolute paths
6
+ */
7
+ declare function rehypeValidateLinks(): (tree: Root, file: VFile) => void;
8
+
9
+ export { rehypeValidateLinks as default, rehypeValidateLinks };
@@ -0,0 +1,8 @@
1
+ import {
2
+ rehypeValidateLinks,
3
+ rehype_validate_links_default
4
+ } from "../chunk-WOOF7XZX.js";
5
+ export {
6
+ rehype_validate_links_default as default,
7
+ rehypeValidateLinks
8
+ };
@@ -0,0 +1,18 @@
1
+ import { HookParameters } from '@astrojs/starlight/types';
2
+
3
+ type TDirectoryConfig = {
4
+ label: string;
5
+ directory: string;
6
+ };
7
+ type TIndexOnlySidebarPluginOptions = {
8
+ directories: Array<TDirectoryConfig>;
9
+ dirnameDeterminesLabel?: boolean;
10
+ };
11
+ declare function starlightIndexOnlySidebar(options: TIndexOnlySidebarPluginOptions): {
12
+ name: string;
13
+ hooks: {
14
+ "config:setup": (hookOptions: HookParameters<"config:setup">) => void;
15
+ };
16
+ };
17
+
18
+ export { starlightIndexOnlySidebar };
@@ -0,0 +1,6 @@
1
+ import {
2
+ starlightIndexOnlySidebar
3
+ } from "../chunk-6XXMT37V.js";
4
+ export {
5
+ starlightIndexOnlySidebar
6
+ };
package/package.json ADDED
@@ -0,0 +1,92 @@
1
+ {
2
+ "name": "starlight-cannoli-plugins",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "description": "Starlight plugins for automatic sidebar generation and link validation",
6
+ "license": "ISC",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ },
14
+ "./starlight-index-only-sidebar": {
15
+ "import": "./dist/plugins/starlight-index-only-sidebar.js",
16
+ "types": "./dist/plugins/starlight-index-only-sidebar.d.ts"
17
+ },
18
+ "./rehype-validate-links": {
19
+ "import": "./dist/plugins/rehype-validate-links.js",
20
+ "types": "./dist/plugins/rehype-validate-links.d.ts"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "keywords": [
27
+ "astro",
28
+ "starlight",
29
+ "plugin",
30
+ "sidebar",
31
+ "rehype",
32
+ "link-validation"
33
+ ],
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/OccasionalCoderByTrade/astro-starlight-plugins"
37
+ },
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"
46
+ },
47
+ "dependencies": {
48
+ "glob": "^13.0.6",
49
+ "unist-util-visit": "^5.0.0",
50
+ "yaml": "^2.4.0"
51
+ },
52
+ "peerDependencies": {
53
+ "@astrojs/starlight": ">=0.30.0",
54
+ "astro": ">=5.0.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "astro": {
58
+ "optional": false
59
+ },
60
+ "@astrojs/starlight": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "devDependencies": {
65
+ "@astrojs/check": "^0.9.6",
66
+ "@astrojs/starlight": "^0.37.6",
67
+ "@expressive-code/plugin-line-numbers": "^0.41.7",
68
+ "@hpcc-js/wasm": "^2.33.1",
69
+ "@types/hast": "^2.3.10",
70
+ "astro": "^5.6.1",
71
+ "hast": "^1.0.0",
72
+ "rehype-graphviz": "^0.3.0",
73
+ "rehype-mathjax": "^7.1.0",
74
+ "rehype-raw": "^7.0.0",
75
+ "remark-math": "^6.0.0",
76
+ "sharp": "^0.34.2",
77
+ "tsup": "^8.3.0",
78
+ "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"
91
+ }
92
+ }