@dogsbay/format-starlight 0.2.0-beta.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/dist/adapter.d.ts +46 -0
- package/dist/adapter.js +11 -0
- package/dist/adapters/cloudflare.d.ts +10 -0
- package/dist/adapters/cloudflare.js +442 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/nav.d.ts +32 -0
- package/dist/nav.js +332 -0
- package/dist/parse-mdx.d.ts +22 -0
- package/dist/parse-mdx.js +554 -0
- package/dist/parse.d.ts +34 -0
- package/dist/parse.js +729 -0
- package/package.json +43 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join, relative, dirname } from "node:path";
|
|
3
|
+
import { parseMeta } from "@dogsbay/types";
|
|
4
|
+
import { starlightToTree as starlightToTreeLegacy, extractFrontmatter } from "./parse.js";
|
|
5
|
+
import { starlightToTree as starlightToTreeMdx } from "./parse-mdx.js";
|
|
6
|
+
import { cloudflareAdapter } from "./adapters/cloudflare.js";
|
|
7
|
+
import { buildNavFromDirectory, buildNavFromConfig } from "./nav.js";
|
|
8
|
+
const ADAPTERS = {
|
|
9
|
+
cloudflare: cloudflareAdapter,
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Detect if a directory contains a Starlight docs source.
|
|
13
|
+
*
|
|
14
|
+
* Checks for:
|
|
15
|
+
* 1. src/content/docs/ directory with .mdx files
|
|
16
|
+
* 2. astro.config with @astrojs/starlight
|
|
17
|
+
* 3. .mdx files with Starlight-style frontmatter (sidebar.order)
|
|
18
|
+
*/
|
|
19
|
+
function detectStarlightSource(path) {
|
|
20
|
+
// Check for src/content/docs/ pattern
|
|
21
|
+
const docsDir = join(path, "src", "content", "docs");
|
|
22
|
+
if (existsSync(docsDir)) {
|
|
23
|
+
const entries = readdirSync(docsDir, { recursive: true });
|
|
24
|
+
if (entries.some((e) => e.endsWith(".mdx") || e.endsWith(".md"))) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Check for astro.config with starlight
|
|
29
|
+
for (const configName of ["astro.config.ts", "astro.config.mjs", "astro.config.js"]) {
|
|
30
|
+
const configPath = join(path, configName);
|
|
31
|
+
if (existsSync(configPath)) {
|
|
32
|
+
const content = readFileSync(configPath, "utf-8");
|
|
33
|
+
if (content.includes("@astrojs/starlight"))
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Check if the path itself is a docs directory with .mdx files
|
|
38
|
+
if (existsSync(path)) {
|
|
39
|
+
try {
|
|
40
|
+
const entries = readdirSync(path);
|
|
41
|
+
const hasMdx = entries.some((e) => e.endsWith(".mdx") || e.endsWith(".md"));
|
|
42
|
+
// Check a sample file for Starlight frontmatter
|
|
43
|
+
if (hasMdx) {
|
|
44
|
+
const sample = entries.find((e) => e.endsWith(".mdx") || e.endsWith(".md"));
|
|
45
|
+
if (sample) {
|
|
46
|
+
const content = readFileSync(join(path, sample), "utf-8");
|
|
47
|
+
if (content.includes("sidebar:") && content.includes("order:"))
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Not a directory or can't read
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Walk a directory tree and collect all .mdx/.md files.
|
|
60
|
+
*/
|
|
61
|
+
function walkMdxFiles(dir, rootDir) {
|
|
62
|
+
const results = [];
|
|
63
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
64
|
+
const fullPath = join(dir, entry.name);
|
|
65
|
+
if (entry.isDirectory()) {
|
|
66
|
+
results.push(...walkMdxFiles(fullPath, rootDir));
|
|
67
|
+
}
|
|
68
|
+
else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
|
|
69
|
+
const rel = relative(rootDir, fullPath)
|
|
70
|
+
.replace(/\\/g, "/")
|
|
71
|
+
.replace(/\.mdx?$/, "")
|
|
72
|
+
.replace(/\/index$/, "");
|
|
73
|
+
results.push({ filePath: fullPath, slug: rel || "index" });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Import a Starlight docs source directory.
|
|
80
|
+
*/
|
|
81
|
+
async function importStarlight(source, opts) {
|
|
82
|
+
// Determine the docs directory
|
|
83
|
+
let docsDir = source;
|
|
84
|
+
const srcContentDocs = join(source, "src", "content", "docs");
|
|
85
|
+
if (existsSync(srcContentDocs)) {
|
|
86
|
+
docsDir = srcContentDocs;
|
|
87
|
+
}
|
|
88
|
+
// Determine href prefix. Defaults to "/docs" but the CLI threads
|
|
89
|
+
// `site.basePath` (normalized) through opts.hrefPrefix so a site
|
|
90
|
+
// configured for a different prefix (or root-served, "") gets nav
|
|
91
|
+
// hrefs matching where pages will actually be emitted.
|
|
92
|
+
const hrefPrefix = typeof opts.hrefPrefix === "string" ? opts.hrefPrefix : "/docs";
|
|
93
|
+
// Build nav
|
|
94
|
+
let nav = null;
|
|
95
|
+
// Try Mode 1: explicit config
|
|
96
|
+
for (const configName of ["astro.config.ts", "astro.config.mjs", "astro.config.js"]) {
|
|
97
|
+
const configPath = join(source, configName);
|
|
98
|
+
if (existsSync(configPath)) {
|
|
99
|
+
nav = buildNavFromConfig(configPath, docsDir, hrefPrefix);
|
|
100
|
+
if (nav)
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Fall back to Mode 2: directory scan
|
|
105
|
+
if (!nav) {
|
|
106
|
+
nav = buildNavFromDirectory(docsDir, hrefPrefix);
|
|
107
|
+
}
|
|
108
|
+
// Resolve adapter
|
|
109
|
+
const adapterName = opts.adapter;
|
|
110
|
+
const adapter = adapterName ? ADAPTERS[adapterName] : undefined;
|
|
111
|
+
if (adapterName && !adapter) {
|
|
112
|
+
console.warn(`Warning: Unknown adapter "${adapterName}". Available: ${Object.keys(ADAPTERS).join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
// Auto-detect partials directory
|
|
115
|
+
// Walk up from docsDir to find src/content/partials/
|
|
116
|
+
let partialsDir;
|
|
117
|
+
let searchDir = docsDir;
|
|
118
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
119
|
+
const candidate = join(searchDir, "src", "content", "partials");
|
|
120
|
+
if (existsSync(candidate)) {
|
|
121
|
+
partialsDir = candidate;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
// Also check if partials is a sibling of our current path
|
|
125
|
+
const sibling = join(dirname(searchDir), "partials");
|
|
126
|
+
if (existsSync(sibling)) {
|
|
127
|
+
partialsDir = sibling;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
const parent = dirname(searchDir);
|
|
131
|
+
if (parent === searchDir)
|
|
132
|
+
break;
|
|
133
|
+
searchDir = parent;
|
|
134
|
+
}
|
|
135
|
+
// Allow explicit override via CLI option
|
|
136
|
+
if (opts.partialsDir) {
|
|
137
|
+
partialsDir = opts.partialsDir;
|
|
138
|
+
}
|
|
139
|
+
// Parse all pages
|
|
140
|
+
const mdxFiles = walkMdxFiles(docsDir, docsDir);
|
|
141
|
+
const pages = [];
|
|
142
|
+
// Custom taxonomy names from dogsbay.config.yml's `taxonomies:` block
|
|
143
|
+
// (threaded through cli's import-content.ts).
|
|
144
|
+
const taxonomyNames = Array.isArray(opts.taxonomyNames)
|
|
145
|
+
? opts.taxonomyNames
|
|
146
|
+
: undefined;
|
|
147
|
+
for (const { filePath, slug } of mdxFiles) {
|
|
148
|
+
const content = readFileSync(filePath, "utf-8");
|
|
149
|
+
const { frontmatter, raw, body } = extractFrontmatter(content);
|
|
150
|
+
// Use new MDX parser (markdown-it-mdx-jsx plugin) by default
|
|
151
|
+
// Fall back to legacy regex parser with --legacy-parser flag
|
|
152
|
+
const useLegacy = opts.legacyParser === true;
|
|
153
|
+
const starlightToTree = useLegacy ? starlightToTreeLegacy : starlightToTreeMdx;
|
|
154
|
+
const tree = starlightToTree(body, { adapter, partialsDir });
|
|
155
|
+
// Extract headings from tree
|
|
156
|
+
const headings = tree
|
|
157
|
+
.filter((n) => n.type === "heading")
|
|
158
|
+
.map((n) => ({
|
|
159
|
+
depth: n.props?.level,
|
|
160
|
+
text: n.props?.text,
|
|
161
|
+
slug: n.props?.slug,
|
|
162
|
+
}));
|
|
163
|
+
// Check for redirect pages (adapter-configurable frontmatter field)
|
|
164
|
+
let redirect;
|
|
165
|
+
if (adapter?.redirectField) {
|
|
166
|
+
const redirectMatch = content.match(new RegExp(`^${adapter.redirectField}:\\s*(.+)$`, "m"));
|
|
167
|
+
if (redirectMatch) {
|
|
168
|
+
redirect = redirectMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Generate visible content for redirect pages with empty body
|
|
172
|
+
if (redirect && tree.length === 0) {
|
|
173
|
+
tree.push({
|
|
174
|
+
type: "callout",
|
|
175
|
+
props: { variant: "info" },
|
|
176
|
+
children: [
|
|
177
|
+
{
|
|
178
|
+
type: "paragraph",
|
|
179
|
+
children: [
|
|
180
|
+
{
|
|
181
|
+
type: "prose",
|
|
182
|
+
inline: [
|
|
183
|
+
{ type: "text", text: "This page redirects to ", bold: false, italic: false },
|
|
184
|
+
{
|
|
185
|
+
type: "link",
|
|
186
|
+
href: redirect,
|
|
187
|
+
children: [
|
|
188
|
+
{ type: "text", text: frontmatter.title || slug, bold: false, italic: false },
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
{ type: "text", text: ".", bold: false, italic: false },
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const title = frontmatter.title || slug;
|
|
200
|
+
const meta = parseMeta(raw, { slug, taxonomyNames });
|
|
201
|
+
pages.push({
|
|
202
|
+
slug,
|
|
203
|
+
title,
|
|
204
|
+
tree,
|
|
205
|
+
headings,
|
|
206
|
+
redirect,
|
|
207
|
+
frontmatter: raw,
|
|
208
|
+
meta,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Set site name from opts or infer
|
|
212
|
+
if (!opts.siteName && pages.length > 0) {
|
|
213
|
+
const indexPage = pages.find((p) => p.slug === "index" || p.slug === "");
|
|
214
|
+
if (indexPage) {
|
|
215
|
+
opts.siteName = indexPage.title;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { pages, nav };
|
|
219
|
+
}
|
|
220
|
+
export const plugin = {
|
|
221
|
+
name: "starlight",
|
|
222
|
+
canImport: true,
|
|
223
|
+
canExport: false,
|
|
224
|
+
detectSource: detectStarlightSource,
|
|
225
|
+
exportOptions: [
|
|
226
|
+
{ flags: "--adapter <name>", description: "Site adapter for custom components (e.g. cloudflare)" },
|
|
227
|
+
],
|
|
228
|
+
async import(source, opts) {
|
|
229
|
+
return importStarlight(source, opts);
|
|
230
|
+
},
|
|
231
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { starlightToTree as starlightToTreeLegacy, extractFrontmatter } from "./parse.js";
|
|
2
|
+
export { starlightToTree } from "./parse-mdx.js";
|
|
3
|
+
export type { ParseOptions } from "./parse-mdx.js";
|
|
4
|
+
export { buildNavFromDirectory, buildNavFromConfig } from "./nav.js";
|
|
5
|
+
export type { ComponentMapping, SiteAdapter } from "./adapter.js";
|
|
6
|
+
export { cloudflareAdapter } from "./adapters/cloudflare.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { starlightToTree as starlightToTreeLegacy, extractFrontmatter } from "./parse.js";
|
|
2
|
+
export { starlightToTree } from "./parse-mdx.js";
|
|
3
|
+
export { buildNavFromDirectory, buildNavFromConfig } from "./nav.js";
|
|
4
|
+
export { cloudflareAdapter } from "./adapters/cloudflare.js";
|
package/dist/nav.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nav builder for Starlight sites.
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* 1. buildNavFromDirectory — Scan filesystem + frontmatter sidebar.order/label (Mode 2)
|
|
6
|
+
* 2. buildNavFromConfig — Parse explicit sidebar config from astro.config.ts (Mode 1)
|
|
7
|
+
*
|
|
8
|
+
* Both produce the same NavItem[] output.
|
|
9
|
+
*/
|
|
10
|
+
import type { NavItem } from "@dogsbay/types";
|
|
11
|
+
/**
|
|
12
|
+
* Build nav from directory structure and frontmatter sidebar.order.
|
|
13
|
+
*
|
|
14
|
+
* Scans the docs directory, reads frontmatter from each .mdx/.md file,
|
|
15
|
+
* and builds a hierarchical nav sorted by sidebar.order.
|
|
16
|
+
*
|
|
17
|
+
* @param docsDir - The directory containing .mdx source files (e.g. src/content/docs/ddos-protection/)
|
|
18
|
+
* @param hrefPrefix - Prefix for href values (e.g. "/docs/ddos-protection")
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildNavFromDirectory(docsDir: string, hrefPrefix?: string): NavItem[];
|
|
21
|
+
/**
|
|
22
|
+
* Attempt to extract sidebar config from astro.config.ts.
|
|
23
|
+
*
|
|
24
|
+
* This is a best-effort parser — it handles static sidebar arrays
|
|
25
|
+
* but cannot resolve dynamic expressions (e.g. Cloudflare's autogenSections()).
|
|
26
|
+
* Returns null if the sidebar can't be parsed statically.
|
|
27
|
+
*
|
|
28
|
+
* @param configPath - Path to astro.config.ts or astro.config.mjs
|
|
29
|
+
* @param docsDir - Docs directory for resolving autogenerate directives
|
|
30
|
+
* @param hrefPrefix - Prefix for href values
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildNavFromConfig(configPath: string, docsDir: string, hrefPrefix?: string): NavItem[] | null;
|
package/dist/nav.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nav builder for Starlight sites.
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* 1. buildNavFromDirectory — Scan filesystem + frontmatter sidebar.order/label (Mode 2)
|
|
6
|
+
* 2. buildNavFromConfig — Parse explicit sidebar config from astro.config.ts (Mode 1)
|
|
7
|
+
*
|
|
8
|
+
* Both produce the same NavItem[] output.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import { join, relative } from "node:path";
|
|
12
|
+
import { extractFrontmatter } from "./parse.js";
|
|
13
|
+
/**
|
|
14
|
+
* Build nav from directory structure and frontmatter sidebar.order.
|
|
15
|
+
*
|
|
16
|
+
* Scans the docs directory, reads frontmatter from each .mdx/.md file,
|
|
17
|
+
* and builds a hierarchical nav sorted by sidebar.order.
|
|
18
|
+
*
|
|
19
|
+
* @param docsDir - The directory containing .mdx source files (e.g. src/content/docs/ddos-protection/)
|
|
20
|
+
* @param hrefPrefix - Prefix for href values (e.g. "/docs/ddos-protection")
|
|
21
|
+
*/
|
|
22
|
+
export function buildNavFromDirectory(docsDir, hrefPrefix = "/docs") {
|
|
23
|
+
const pages = collectPages(docsDir, docsDir);
|
|
24
|
+
return buildHierarchy(pages, hrefPrefix);
|
|
25
|
+
}
|
|
26
|
+
function collectPages(dir, rootDir) {
|
|
27
|
+
const pages = [];
|
|
28
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
29
|
+
const fullPath = join(dir, entry.name);
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
pages.push(...collectPages(fullPath, rootDir));
|
|
32
|
+
}
|
|
33
|
+
else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
|
|
34
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
35
|
+
const { frontmatter } = extractFrontmatter(content);
|
|
36
|
+
if (frontmatter.sidebarHidden)
|
|
37
|
+
continue;
|
|
38
|
+
// Build slug from file path relative to root dir
|
|
39
|
+
let slug = relative(rootDir, fullPath)
|
|
40
|
+
.replace(/\\/g, "/")
|
|
41
|
+
.replace(/\.mdx?$/, "")
|
|
42
|
+
.replace(/\/index$/, "");
|
|
43
|
+
pages.push({
|
|
44
|
+
title: frontmatter.title,
|
|
45
|
+
sidebarLabel: frontmatter.sidebarLabel,
|
|
46
|
+
sidebarOrder: frontmatter.sidebarOrder,
|
|
47
|
+
sidebarHidden: frontmatter.sidebarHidden,
|
|
48
|
+
hideIndex: frontmatter.hideIndex,
|
|
49
|
+
slug,
|
|
50
|
+
filePath: fullPath,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return pages;
|
|
55
|
+
}
|
|
56
|
+
function buildHierarchy(pages, hrefPrefix) {
|
|
57
|
+
// Determine which slugs are directory index pages (i.e. there are other pages under that path)
|
|
58
|
+
const allSlugs = new Set(pages.map((p) => p.slug));
|
|
59
|
+
const directorySlugs = new Set();
|
|
60
|
+
for (const page of pages) {
|
|
61
|
+
const parts = page.slug.split("/");
|
|
62
|
+
if (parts.length > 1) {
|
|
63
|
+
// Mark all ancestor directories
|
|
64
|
+
for (let i = 1; i < parts.length; i++) {
|
|
65
|
+
directorySlugs.add(parts.slice(0, i).join("/"));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Group pages by their top-level directory
|
|
70
|
+
const groups = new Map();
|
|
71
|
+
const topLevel = [];
|
|
72
|
+
for (const page of pages) {
|
|
73
|
+
const parts = page.slug.split("/");
|
|
74
|
+
if (parts.length > 1) {
|
|
75
|
+
// Has parent directory — group under top-level dir
|
|
76
|
+
const group = parts[0];
|
|
77
|
+
if (!groups.has(group))
|
|
78
|
+
groups.set(group, []);
|
|
79
|
+
groups.get(group).push(page);
|
|
80
|
+
}
|
|
81
|
+
else if (directorySlugs.has(page.slug)) {
|
|
82
|
+
// This is an index page for a directory — it becomes the group header, not a top-level item
|
|
83
|
+
const group = page.slug;
|
|
84
|
+
if (!groups.has(group))
|
|
85
|
+
groups.set(group, []);
|
|
86
|
+
groups.get(group).push(page);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// True top-level leaf page
|
|
90
|
+
topLevel.push(page);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const entries = [];
|
|
94
|
+
// Top-level leaf pages
|
|
95
|
+
for (const page of topLevel) {
|
|
96
|
+
entries.push({
|
|
97
|
+
order: page.sidebarOrder ?? 999,
|
|
98
|
+
item: pageToNavItem(page, hrefPrefix),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
// Groups
|
|
102
|
+
for (const [groupName, groupPages] of groups) {
|
|
103
|
+
const indexPage = groupPages.find((p) => p.slug === groupName);
|
|
104
|
+
// Group label uses title, not sidebarLabel.
|
|
105
|
+
const label = indexPage?.title || slugToLabel(groupName);
|
|
106
|
+
const children = buildGroupChildren(groupPages, groupName, hrefPrefix);
|
|
107
|
+
if (children.length > 0) {
|
|
108
|
+
const href = indexPage ? `${hrefPrefix}/${indexPage.slug}` : undefined;
|
|
109
|
+
entries.push({
|
|
110
|
+
order: indexPage?.sidebarOrder ?? 999,
|
|
111
|
+
item: { label, href, children },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Sort by order, then alphabetically
|
|
116
|
+
entries.sort((a, b) => {
|
|
117
|
+
if (a.order !== b.order)
|
|
118
|
+
return a.order - b.order;
|
|
119
|
+
return (a.item.label || "").localeCompare(b.item.label || "");
|
|
120
|
+
});
|
|
121
|
+
const nav = entries.map((e) => e.item);
|
|
122
|
+
return nav;
|
|
123
|
+
}
|
|
124
|
+
function buildGroupChildren(pages, groupName, hrefPrefix) {
|
|
125
|
+
// Separate into sub-groups and leaf pages
|
|
126
|
+
const subGroups = new Map();
|
|
127
|
+
const leaves = [];
|
|
128
|
+
// Find the index page for this group
|
|
129
|
+
const indexPage = pages.find((p) => p.slug === groupName);
|
|
130
|
+
// First pass: collect sub-group directories
|
|
131
|
+
for (const page of pages) {
|
|
132
|
+
if (page.slug === groupName)
|
|
133
|
+
continue;
|
|
134
|
+
const rel = page.slug.startsWith(groupName + "/")
|
|
135
|
+
? page.slug.slice(groupName.length + 1)
|
|
136
|
+
: page.slug;
|
|
137
|
+
const parts = rel.split("/");
|
|
138
|
+
if (parts.length > 1) {
|
|
139
|
+
const subGroup = parts[0];
|
|
140
|
+
const key = `${groupName}/${subGroup}`;
|
|
141
|
+
if (!subGroups.has(key))
|
|
142
|
+
subGroups.set(key, []);
|
|
143
|
+
subGroups.get(key).push(page);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Second pass: collect leaf pages, but skip those that are index pages of sub-groups
|
|
147
|
+
for (const page of pages) {
|
|
148
|
+
if (page.slug === groupName)
|
|
149
|
+
continue;
|
|
150
|
+
const rel = page.slug.startsWith(groupName + "/")
|
|
151
|
+
? page.slug.slice(groupName.length + 1)
|
|
152
|
+
: page.slug;
|
|
153
|
+
const parts = rel.split("/");
|
|
154
|
+
if (parts.length === 1) {
|
|
155
|
+
// Check if this leaf's slug matches a sub-group directory
|
|
156
|
+
const potentialSubGroup = `${groupName}/${rel}`;
|
|
157
|
+
if (subGroups.has(potentialSubGroup)) {
|
|
158
|
+
// This is the index page for a sub-group — add it to that sub-group's pages
|
|
159
|
+
subGroups.get(potentialSubGroup).push(page);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
leaves.push(page);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const children = [];
|
|
167
|
+
// Add index page as first child of the group — unless hideIndex is set.
|
|
168
|
+
// Starlight's sidebar.group.hideIndex suppresses the index page from children.
|
|
169
|
+
// When shown: "Overview" if title matches group label, else the page's own title.
|
|
170
|
+
if (indexPage && !indexPage.hideIndex) {
|
|
171
|
+
const indexTitle = indexPage.sidebarLabel || indexPage.title || "";
|
|
172
|
+
const normalize = (s) => s.toLowerCase().replace(/[\s-]+/g, "");
|
|
173
|
+
const groupSlug = groupName.split("/").pop() || groupName;
|
|
174
|
+
const isGenericIndex = normalize(indexTitle) === normalize(groupSlug) ||
|
|
175
|
+
normalize(indexTitle) === normalize(slugToLabel(groupSlug));
|
|
176
|
+
children.push({
|
|
177
|
+
label: isGenericIndex ? "Overview" : indexTitle,
|
|
178
|
+
href: `${hrefPrefix}/${indexPage.slug}`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const entries = [];
|
|
182
|
+
// Leaves
|
|
183
|
+
for (const page of leaves) {
|
|
184
|
+
entries.push({
|
|
185
|
+
order: page.sidebarOrder ?? 999,
|
|
186
|
+
item: pageToNavItem(page, hrefPrefix),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// Sub-groups
|
|
190
|
+
for (const [subGroupPath, subPages] of subGroups) {
|
|
191
|
+
const subGroupSlug = subGroupPath.split("/").pop();
|
|
192
|
+
const subIndex = subPages.find((p) => p.slug === subGroupPath);
|
|
193
|
+
const label = subIndex?.title || slugToLabel(subGroupSlug);
|
|
194
|
+
const order = subIndex?.sidebarOrder ?? 999;
|
|
195
|
+
const subChildren = buildGroupChildren(subPages, subGroupPath, hrefPrefix);
|
|
196
|
+
if (subChildren.length > 0) {
|
|
197
|
+
const href = subIndex ? `${hrefPrefix}/${subIndex.slug}` : undefined;
|
|
198
|
+
entries.push({ order, item: { label, href, children: subChildren } });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Sort by order, then alphabetically
|
|
202
|
+
entries.sort((a, b) => {
|
|
203
|
+
if (a.order !== b.order)
|
|
204
|
+
return a.order - b.order;
|
|
205
|
+
return (a.item.label || "").localeCompare(b.item.label || "");
|
|
206
|
+
});
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
children.push(entry.item);
|
|
209
|
+
}
|
|
210
|
+
return children;
|
|
211
|
+
}
|
|
212
|
+
function pageToNavItem(page, hrefPrefix) {
|
|
213
|
+
const label = page.sidebarLabel || page.title || slugToLabel(page.slug);
|
|
214
|
+
const href = `${hrefPrefix}/${page.slug}`;
|
|
215
|
+
return { label, href };
|
|
216
|
+
}
|
|
217
|
+
function sortPages(pages) {
|
|
218
|
+
pages.sort((a, b) => {
|
|
219
|
+
const orderA = a.sidebarOrder ?? 999;
|
|
220
|
+
const orderB = b.sidebarOrder ?? 999;
|
|
221
|
+
if (orderA !== orderB)
|
|
222
|
+
return orderA - orderB;
|
|
223
|
+
return a.title.localeCompare(b.title);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function slugToLabel(slug) {
|
|
227
|
+
const name = slug.split("/").pop() || slug;
|
|
228
|
+
return name
|
|
229
|
+
.split("-")
|
|
230
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
231
|
+
.join(" ");
|
|
232
|
+
}
|
|
233
|
+
// ── Mode 1: Build nav from astro.config sidebar ─────────
|
|
234
|
+
/**
|
|
235
|
+
* Attempt to extract sidebar config from astro.config.ts.
|
|
236
|
+
*
|
|
237
|
+
* This is a best-effort parser — it handles static sidebar arrays
|
|
238
|
+
* but cannot resolve dynamic expressions (e.g. Cloudflare's autogenSections()).
|
|
239
|
+
* Returns null if the sidebar can't be parsed statically.
|
|
240
|
+
*
|
|
241
|
+
* @param configPath - Path to astro.config.ts or astro.config.mjs
|
|
242
|
+
* @param docsDir - Docs directory for resolving autogenerate directives
|
|
243
|
+
* @param hrefPrefix - Prefix for href values
|
|
244
|
+
*/
|
|
245
|
+
export function buildNavFromConfig(configPath, docsDir, hrefPrefix = "/docs") {
|
|
246
|
+
let content;
|
|
247
|
+
try {
|
|
248
|
+
content = readFileSync(configPath, "utf-8");
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
// Find the sidebar array — look for sidebar: [ ... ]
|
|
254
|
+
const sidebarMatch = content.match(/sidebar:\s*\[/);
|
|
255
|
+
if (!sidebarMatch)
|
|
256
|
+
return null;
|
|
257
|
+
// Extract the array by tracking bracket depth
|
|
258
|
+
const start = sidebarMatch.index + sidebarMatch[0].length - 1;
|
|
259
|
+
let depth = 0;
|
|
260
|
+
let end = start;
|
|
261
|
+
for (let i = start; i < content.length; i++) {
|
|
262
|
+
if (content[i] === "[")
|
|
263
|
+
depth++;
|
|
264
|
+
else if (content[i] === "]") {
|
|
265
|
+
depth--;
|
|
266
|
+
if (depth === 0) {
|
|
267
|
+
end = i + 1;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const sidebarStr = content.slice(start, end);
|
|
273
|
+
// Try to parse as JSON5-like (remove trailing commas, handle unquoted keys)
|
|
274
|
+
let sidebarData;
|
|
275
|
+
try {
|
|
276
|
+
const jsonLike = sidebarStr
|
|
277
|
+
.replace(/(\w+):/g, '"$1":') // quote keys
|
|
278
|
+
.replace(/'/g, '"') // single to double quotes
|
|
279
|
+
.replace(/,\s*([}\]])/g, "$1") // trailing commas
|
|
280
|
+
.replace(/\/\/.*/g, ""); // strip comments
|
|
281
|
+
sidebarData = JSON.parse(jsonLike);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// Config is too dynamic to parse statically
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
return sidebarDataToNav(sidebarData, docsDir, hrefPrefix);
|
|
288
|
+
}
|
|
289
|
+
function sidebarDataToNav(items, docsDir, hrefPrefix) {
|
|
290
|
+
const nav = [];
|
|
291
|
+
for (const item of items) {
|
|
292
|
+
if (typeof item === "string") {
|
|
293
|
+
// Slug shorthand
|
|
294
|
+
nav.push({ label: slugToLabel(item), href: `${hrefPrefix}/${item}` });
|
|
295
|
+
}
|
|
296
|
+
else if (typeof item === "object" && item !== null) {
|
|
297
|
+
const obj = item;
|
|
298
|
+
if (obj.slug) {
|
|
299
|
+
// Explicit slug reference
|
|
300
|
+
const slug = obj.slug;
|
|
301
|
+
const label = obj.label || slugToLabel(slug);
|
|
302
|
+
nav.push({ label, href: `${hrefPrefix}/${slug}` });
|
|
303
|
+
}
|
|
304
|
+
else if (obj.link) {
|
|
305
|
+
// URL link
|
|
306
|
+
const label = obj.label || "";
|
|
307
|
+
nav.push({ label, href: obj.link });
|
|
308
|
+
}
|
|
309
|
+
else if (obj.items) {
|
|
310
|
+
// Group with nested items
|
|
311
|
+
const label = obj.label || "";
|
|
312
|
+
const children = sidebarDataToNav(obj.items, docsDir, hrefPrefix);
|
|
313
|
+
nav.push({ label, children });
|
|
314
|
+
}
|
|
315
|
+
else if (obj.autogenerate) {
|
|
316
|
+
// Autogenerate from directory
|
|
317
|
+
const label = obj.label || "";
|
|
318
|
+
const dir = obj.autogenerate.directory;
|
|
319
|
+
const fullDir = join(docsDir, dir);
|
|
320
|
+
try {
|
|
321
|
+
const children = buildNavFromDirectory(fullDir, `${hrefPrefix}/${dir}`);
|
|
322
|
+
nav.push({ label, children });
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// Directory doesn't exist or can't be read
|
|
326
|
+
nav.push({ label, children: [] });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return nav;
|
|
332
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starlight MDX parser — v2 using markdown-it-mdx-jsx plugin.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the regex-based segmentMdx approach with proper JSX token handling.
|
|
5
|
+
* The markdown-it-mdx-jsx plugin emits jsx_open/jsx_close/jsx_self_closing
|
|
6
|
+
* tokens that are handled alongside standard markdown tokens.
|
|
7
|
+
*
|
|
8
|
+
* Keeps:
|
|
9
|
+
* - Directive conversion (:::note → <Aside>)
|
|
10
|
+
* - Inline transforms (adapter.inlineTransforms)
|
|
11
|
+
* - Steps unwrapping (steps > ordered-list > list-item → steps > step)
|
|
12
|
+
* - Card grouping (consecutive cards → cards container)
|
|
13
|
+
* - The adapter pattern for site-specific component mappings
|
|
14
|
+
*/
|
|
15
|
+
import type { TreeNode } from "@dogsbay/types";
|
|
16
|
+
import type { SiteAdapter } from "./adapter.js";
|
|
17
|
+
export interface ParseOptions {
|
|
18
|
+
adapter?: SiteAdapter;
|
|
19
|
+
/** Directory containing partial MDX files for <Render> resolution */
|
|
20
|
+
partialsDir?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function starlightToTree(source: string, options?: ParseOptions): TreeNode[];
|