@opendocsdev/cli 0.2.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.
Files changed (78) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +300 -0
  3. package/dist/bin/opendocs.js +712 -0
  4. package/dist/bin/opendocs.js.map +1 -0
  5. package/dist/templates/api-reference.mdx +308 -0
  6. package/dist/templates/components.mdx +286 -0
  7. package/dist/templates/configuration.mdx +190 -0
  8. package/dist/templates/docs.json +27 -0
  9. package/dist/templates/introduction.mdx +25 -0
  10. package/dist/templates/logo.svg +4 -0
  11. package/dist/templates/quickstart.mdx +59 -0
  12. package/dist/templates/writing-content.mdx +236 -0
  13. package/package.json +92 -0
  14. package/src/engine/astro.config.ts +75 -0
  15. package/src/engine/src/components/Analytics.astro +57 -0
  16. package/src/engine/src/components/ApiPlayground.astro +24 -0
  17. package/src/engine/src/components/Callout.astro +66 -0
  18. package/src/engine/src/components/Card.astro +75 -0
  19. package/src/engine/src/components/CardGroup.astro +29 -0
  20. package/src/engine/src/components/CodeGroup.astro +231 -0
  21. package/src/engine/src/components/CopyButton.astro +179 -0
  22. package/src/engine/src/components/Steps.astro +27 -0
  23. package/src/engine/src/components/Tab.astro +21 -0
  24. package/src/engine/src/components/TableOfContents.astro +119 -0
  25. package/src/engine/src/components/Tabs.astro +135 -0
  26. package/src/engine/src/components/index.ts +107 -0
  27. package/src/engine/src/components/react/ApiPlayground/AuthSection.tsx +91 -0
  28. package/src/engine/src/components/react/ApiPlayground/CodeBlock.tsx +66 -0
  29. package/src/engine/src/components/react/ApiPlayground/CodeSnippets.tsx +66 -0
  30. package/src/engine/src/components/react/ApiPlayground/CollapsibleSection.tsx +26 -0
  31. package/src/engine/src/components/react/ApiPlayground/KeyValueEditor.tsx +58 -0
  32. package/src/engine/src/components/react/ApiPlayground/ResponseDisplay.tsx +109 -0
  33. package/src/engine/src/components/react/ApiPlayground/Spinner.tsx +6 -0
  34. package/src/engine/src/components/react/ApiPlayground/constants.ts +16 -0
  35. package/src/engine/src/components/react/ApiPlayground/generators.test.ts +130 -0
  36. package/src/engine/src/components/react/ApiPlayground/generators.ts +75 -0
  37. package/src/engine/src/components/react/ApiPlayground/index.tsx +490 -0
  38. package/src/engine/src/components/react/ApiPlayground/types.ts +35 -0
  39. package/src/engine/src/components/react/Callout.tsx +54 -0
  40. package/src/engine/src/components/react/Card.tsx +48 -0
  41. package/src/engine/src/components/react/CardGroup.tsx +24 -0
  42. package/src/engine/src/components/react/FeedbackWidget.tsx +166 -0
  43. package/src/engine/src/components/react/GitHubLink.tsx +28 -0
  44. package/src/engine/src/components/react/NavigationCard.tsx +53 -0
  45. package/src/engine/src/components/react/PageActions.tsx +124 -0
  46. package/src/engine/src/components/react/PageFooter.tsx +91 -0
  47. package/src/engine/src/components/react/SearchModal.tsx +358 -0
  48. package/src/engine/src/components/react/SearchProvider.tsx +37 -0
  49. package/src/engine/src/components/react/Sidebar.tsx +369 -0
  50. package/src/engine/src/components/react/SidebarSearchTrigger.tsx +57 -0
  51. package/src/engine/src/components/react/Steps.tsx +25 -0
  52. package/src/engine/src/components/react/ThemeToggle.tsx +72 -0
  53. package/src/engine/src/components/react/index.ts +14 -0
  54. package/src/engine/src/env.d.ts +10 -0
  55. package/src/engine/src/layouts/DocsLayout.astro +357 -0
  56. package/src/engine/src/lib/__tests__/markdown.test.ts +124 -0
  57. package/src/engine/src/lib/__tests__/mdx-loader.test.ts +205 -0
  58. package/src/engine/src/lib/config.ts +79 -0
  59. package/src/engine/src/lib/markdown.ts +54 -0
  60. package/src/engine/src/lib/mdx-loader.ts +143 -0
  61. package/src/engine/src/lib/mdx-utils.ts +72 -0
  62. package/src/engine/src/lib/remark-opendocs.ts +195 -0
  63. package/src/engine/src/lib/utils.ts +221 -0
  64. package/src/engine/src/pages/[...slug].astro +115 -0
  65. package/src/engine/src/pages/index.astro +71 -0
  66. package/src/engine/src/scripts/mobile-sidebar.ts +56 -0
  67. package/src/engine/src/scripts/theme-init.ts +25 -0
  68. package/src/engine/src/styles/global.css +703 -0
  69. package/src/engine/tailwind.config.mjs +60 -0
  70. package/src/engine/tsconfig.json +15 -0
  71. package/src/templates/api-reference.mdx +308 -0
  72. package/src/templates/components.mdx +286 -0
  73. package/src/templates/configuration.mdx +190 -0
  74. package/src/templates/docs.json +27 -0
  75. package/src/templates/introduction.mdx +25 -0
  76. package/src/templates/logo.svg +4 -0
  77. package/src/templates/quickstart.mdx +59 -0
  78. package/src/templates/writing-content.mdx +236 -0
@@ -0,0 +1,357 @@
1
+ ---
2
+ /**
3
+ * DocsLayout - Main documentation page layout
4
+ * Provides sidebar navigation, table of contents, and page footer
5
+ */
6
+ import TableOfContents from "../components/TableOfContents.astro";
7
+ import Analytics from "../components/Analytics.astro";
8
+ import CopyButton from "../components/CopyButton.astro";
9
+ import { Sidebar } from "../components/react/Sidebar";
10
+ import { PageFooter } from "../components/react/PageFooter";
11
+ import { PageActions } from "../components/react/PageActions";
12
+ import { SearchProvider } from "../components/react/SearchProvider";
13
+ import { loadConfig } from "../lib/config.js";
14
+ import { generateColorVariants, getPageNavigation } from "../lib/utils";
15
+ import "../styles/global.css";
16
+
17
+ interface Props {
18
+ title: string;
19
+ description?: string;
20
+ headings?: { depth: number; slug: string; text: string }[];
21
+ path?: string;
22
+ lastUpdated?: string;
23
+ }
24
+
25
+ const { title, description, headings = [], path, lastUpdated } = Astro.props;
26
+
27
+ // Load cached config
28
+ const config = await loadConfig();
29
+
30
+ // Extract config values
31
+ const currentPath = path || Astro.url.pathname;
32
+ const siteName = config.name || "Documentation";
33
+ const faviconPath = config.favicon || "/favicon.ico";
34
+ const navigation = config.navigation || [];
35
+ const logo = config.logo;
36
+ const githubUrl = config.socialLinks?.github;
37
+ const feedbackEnabled = config.features?.feedback !== false;
38
+
39
+ // Backend config
40
+ const backendConfig = config.backend as { apiUrl?: string; siteId?: string } | undefined;
41
+ const backend = backendConfig?.apiUrl;
42
+ const siteId = backendConfig?.siteId;
43
+
44
+ // Theme colors
45
+ const primaryColor = config.theme?.primaryColor || "#3b82f6";
46
+ const accentColor = config.theme?.accentColor || primaryColor;
47
+ const primary = generateColorVariants(primaryColor);
48
+ const accent = generateColorVariants(accentColor);
49
+
50
+ // SEO metadata
51
+ const siteUrl = (config.metadata as { url?: string; ogImage?: string } | undefined)?.url || "";
52
+ const ogImage = (config.metadata as { url?: string; ogImage?: string } | undefined)?.ogImage;
53
+ const canonicalUrl = siteUrl ? `${siteUrl}${currentPath}` : "";
54
+ const pageDescription = description || `${title} - ${siteName}`;
55
+ const fullTitle = `${title} | ${siteName}`;
56
+
57
+ // Build breadcrumb segments from current path
58
+ const pathSegments = currentPath.split("/").filter(Boolean);
59
+ const breadcrumbItems = pathSegments.map((segment, i) => ({
60
+ name: segment.replace(/-/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase()),
61
+ url: `${siteUrl}/${pathSegments.slice(0, i + 1).join("/")}/`,
62
+ }));
63
+
64
+ // Page navigation (previous/next links)
65
+ const { previous: previousPage, next: nextPage } = getPageNavigation(navigation, currentPath);
66
+ ---
67
+
68
+ <!doctype html>
69
+ <html lang="en">
70
+ <head>
71
+ <meta charset="UTF-8" />
72
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
73
+ <meta name="description" content={pageDescription} />
74
+ <link rel="icon" type="image/x-icon" href={faviconPath} />
75
+ <title>{fullTitle}</title>
76
+
77
+ {/* Canonical URL */}
78
+ {canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
79
+
80
+ {/* Theme color */}
81
+ <meta name="theme-color" content={primaryColor} />
82
+
83
+ {/* Open Graph */}
84
+ <meta property="og:type" content="article" />
85
+ <meta property="og:title" content={fullTitle} />
86
+ <meta property="og:description" content={pageDescription} />
87
+ <meta property="og:site_name" content={siteName} />
88
+ {canonicalUrl && <meta property="og:url" content={canonicalUrl} />}
89
+ {ogImage && <meta property="og:image" content={ogImage.startsWith("http") ? ogImage : `${siteUrl}${ogImage}`} />}
90
+
91
+ {/* Twitter Card */}
92
+ <meta name="twitter:card" content={ogImage ? "summary_large_image" : "summary"} />
93
+ <meta name="twitter:title" content={fullTitle} />
94
+ <meta name="twitter:description" content={pageDescription} />
95
+ {ogImage && <meta name="twitter:image" content={ogImage.startsWith("http") ? ogImage : `${siteUrl}${ogImage}`} />}
96
+
97
+ {/* Structured Data */}
98
+ {siteUrl && (
99
+ <script type="application/ld+json" set:html={JSON.stringify({
100
+ "@context": "https://schema.org",
101
+ "@graph": [
102
+ {
103
+ "@type": "WebSite",
104
+ name: siteName,
105
+ url: siteUrl,
106
+ },
107
+ ...(breadcrumbItems.length > 0 ? [{
108
+ "@type": "BreadcrumbList",
109
+ itemListElement: breadcrumbItems.map((item, i) => ({
110
+ "@type": "ListItem",
111
+ position: i + 1,
112
+ name: item.name,
113
+ item: item.url,
114
+ })),
115
+ }] : []),
116
+ ],
117
+ })} />
118
+ )}
119
+
120
+ <Analytics path={currentPath} />
121
+
122
+ <!-- Theme init script - prevents flash of wrong theme -->
123
+ <script is:inline>
124
+ (function () {
125
+ const storedTheme = localStorage.getItem("theme");
126
+ if (storedTheme === "dark") {
127
+ document.documentElement.classList.add("dark");
128
+ } else if (storedTheme === "light") {
129
+ document.documentElement.classList.remove("dark");
130
+ } else {
131
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
132
+ if (prefersDark) {
133
+ document.documentElement.classList.add("dark");
134
+ }
135
+ }
136
+ })();
137
+ </script>
138
+
139
+ <!-- Theme injection from docs.json config -->
140
+ <style set:html={`
141
+ :root {
142
+ --color-primary: ${primary.base};
143
+ --color-primary-light: ${primary.light};
144
+ --color-primary-dark: ${primary.dark};
145
+ --color-accent: ${accent.base};
146
+ --color-accent-light: ${accent.light};
147
+ }
148
+ .dark {
149
+ --color-primary: ${primary.darkMode};
150
+ --color-primary-light: ${primary.lightDark};
151
+ --color-accent-light: ${accent.lightDark};
152
+ }
153
+ `}></style>
154
+ </head>
155
+
156
+ <body class="min-h-screen bg-[var(--color-background)]">
157
+ <div class="flex min-h-screen">
158
+ <!-- Desktop Sidebar -->
159
+ <aside class="hidden lg:block fixed inset-y-0 left-0 z-40 w-64 border-r border-[var(--color-border)] bg-[var(--color-surface-raised)]">
160
+ <Sidebar
161
+ client:load
162
+ siteName={siteName}
163
+ navigation={navigation}
164
+ logo={logo}
165
+ currentPath={currentPath}
166
+ githubUrl={githubUrl}
167
+ />
168
+ </aside>
169
+
170
+ <!-- Mobile Sidebar Backdrop -->
171
+ <div
172
+ data-backdrop
173
+ class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300 lg:hidden"
174
+ aria-hidden="true"
175
+ ></div>
176
+
177
+ <!-- Mobile Sidebar -->
178
+ <aside
179
+ data-mobile-sidebar
180
+ class="fixed inset-y-0 left-0 z-50 w-[280px] bg-[var(--color-surface-raised)] border-r border-[var(--color-border)] transform -translate-x-full transition-transform duration-300 ease-in-out lg:hidden"
181
+ >
182
+ <div class="h-full overflow-y-auto">
183
+ <Sidebar
184
+ client:load
185
+ siteName={siteName}
186
+ navigation={navigation}
187
+ logo={logo}
188
+ currentPath={currentPath}
189
+ githubUrl={githubUrl}
190
+ />
191
+ </div>
192
+ </aside>
193
+
194
+ <!-- Main content area -->
195
+ <div class="flex-1 lg:ml-64">
196
+ <!-- Mobile Header -->
197
+ <header class="sticky top-0 z-30 border-b border-[var(--color-border)] bg-[var(--color-background)]/95 backdrop-blur-sm lg:hidden">
198
+ <div class="flex items-center justify-between h-16 px-4">
199
+ <!-- Hamburger menu -->
200
+ <button
201
+ type="button"
202
+ data-hamburger
203
+ class="flex items-center justify-center w-11 h-11 -ml-2 text-[var(--color-muted)] hover:text-[var(--color-foreground)] rounded-md hover:bg-[var(--color-surface-raised)] transition-colors"
204
+ aria-label="Open navigation menu"
205
+ >
206
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
207
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
208
+ </svg>
209
+ </button>
210
+
211
+ <!-- Logo/site name -->
212
+ <a href="/" class="flex items-center justify-center">
213
+ {logo ? (
214
+ typeof logo === "string" ? (
215
+ <img src={logo} alt={siteName} class="h-6 w-auto" />
216
+ ) : logo.dark ? (
217
+ <>
218
+ <img src={logo.light} alt={siteName} class="h-6 w-auto dark:hidden" />
219
+ <img src={logo.dark} alt={siteName} class="h-6 w-auto hidden dark:block" />
220
+ </>
221
+ ) : (
222
+ <img src={logo.light} alt={siteName} class="h-6 w-auto" />
223
+ )
224
+ ) : (
225
+ <span class="text-lg font-semibold text-[var(--color-foreground)]">{siteName}</span>
226
+ )}
227
+ </a>
228
+
229
+ <!-- Search button -->
230
+ <button
231
+ type="button"
232
+ data-search-trigger
233
+ class="flex items-center justify-center w-11 h-11 -mr-2 text-[var(--color-muted)] hover:text-[var(--color-foreground)] rounded-md hover:bg-[var(--color-surface-raised)] transition-colors"
234
+ aria-label="Search documentation"
235
+ >
236
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
237
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
238
+ </svg>
239
+ </button>
240
+ </div>
241
+ </header>
242
+
243
+ <!-- Main content with table of contents -->
244
+ <div class="flex justify-center">
245
+ <!-- Article content -->
246
+ <main class="min-w-0 flex-1 px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-6 lg:py-8 max-w-4xl">
247
+ <div id="page-actions-container" class="hidden">
248
+ <PageActions client:load githubEditUrl={githubUrl ? `${githubUrl}/edit/main${currentPath}.mdx` : undefined} />
249
+ </div>
250
+ <article class="prose prose-gray max-w-none">
251
+ <slot />
252
+ </article>
253
+ <PageFooter
254
+ client:load
255
+ path={currentPath}
256
+ backend={backend}
257
+ siteId={siteId}
258
+ feedbackEnabled={feedbackEnabled}
259
+ previousPage={previousPage}
260
+ nextPage={nextPage}
261
+ lastUpdated={lastUpdated}
262
+ />
263
+ <CopyButton />
264
+ </main>
265
+
266
+ <!-- Table of contents -->
267
+ {headings.length > 0 && (
268
+ <aside class="hidden xl:block w-56 flex-shrink-0">
269
+ <div class="sticky top-8 py-8">
270
+ <TableOfContents headings={headings} />
271
+ </div>
272
+ </aside>
273
+ )}
274
+ </div>
275
+ </div>
276
+ </div>
277
+
278
+ <!-- Search modal -->
279
+ <SearchProvider client:load backend={backend} siteId={siteId} />
280
+
281
+ <!-- Mobile sidebar toggle script -->
282
+ <script src="../scripts/mobile-sidebar.ts"></script>
283
+
284
+ <!-- Search trigger for mobile header -->
285
+ <script>
286
+ function initSearchTriggers() {
287
+ const searchTriggers = document.querySelectorAll('[data-search-trigger]');
288
+ searchTriggers.forEach((trigger) => {
289
+ trigger.addEventListener('click', () => {
290
+ document.dispatchEvent(new CustomEvent('open-search'));
291
+ });
292
+ });
293
+ }
294
+
295
+ if (document.readyState === 'loading') {
296
+ document.addEventListener('DOMContentLoaded', initSearchTriggers);
297
+ } else {
298
+ initSearchTriggers();
299
+ }
300
+ document.addEventListener('astro:after-swap', initSearchTriggers);
301
+ </script>
302
+
303
+ <!-- Inject page actions after first heading -->
304
+ <script>
305
+ function injectPageActions() {
306
+ const container = document.getElementById('page-actions-container');
307
+ const article = document.querySelector('article');
308
+ if (!container || !article) return;
309
+
310
+ const h1 = article.querySelector('h1');
311
+ if (!h1) return;
312
+
313
+ let insertAfter: Element = h1;
314
+ const nextSibling = h1.nextElementSibling;
315
+ if (nextSibling && nextSibling.tagName === 'P') {
316
+ insertAfter = nextSibling;
317
+ }
318
+
319
+ container.classList.remove('hidden');
320
+ container.classList.add('my-6');
321
+ insertAfter.after(container);
322
+ }
323
+
324
+ if (document.readyState === 'loading') {
325
+ document.addEventListener('DOMContentLoaded', injectPageActions);
326
+ } else {
327
+ injectPageActions();
328
+ }
329
+ document.addEventListener('astro:after-swap', injectPageActions);
330
+ </script>
331
+
332
+ <!-- Wrap tables in scrollable containers -->
333
+ <script>
334
+ function wrapTables() {
335
+ const article = document.querySelector('article');
336
+ if (!article) return;
337
+
338
+ const tables = article.querySelectorAll('table:not(.table-wrapper table)');
339
+ tables.forEach((table) => {
340
+ if (table.parentElement?.classList.contains('table-wrapper')) return;
341
+
342
+ const wrapper = document.createElement('div');
343
+ wrapper.className = 'table-wrapper';
344
+ table.parentNode?.insertBefore(wrapper, table);
345
+ wrapper.appendChild(table);
346
+ });
347
+ }
348
+
349
+ if (document.readyState === 'loading') {
350
+ document.addEventListener('DOMContentLoaded', wrapTables);
351
+ } else {
352
+ wrapTables();
353
+ }
354
+ document.addEventListener('astro:after-swap', wrapTables);
355
+ </script>
356
+ </body>
357
+ </html>
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseFrontmatter, extractHeadings } from "../markdown.js";
3
+
4
+ describe("parseFrontmatter", () => {
5
+ it("parses frontmatter with title and description", () => {
6
+ const content = `---
7
+ title: Hello World
8
+ description: A test page
9
+ ---
10
+
11
+ # Content here`;
12
+
13
+ const result = parseFrontmatter(content);
14
+
15
+ expect(result.frontmatter.title).toBe("Hello World");
16
+ expect(result.frontmatter.description).toBe("A test page");
17
+ expect(result.body).toBe("# Content here");
18
+ });
19
+
20
+ it("handles quoted values in frontmatter", () => {
21
+ const content = `---
22
+ title: "Hello World"
23
+ description: 'A test page'
24
+ ---
25
+
26
+ Content`;
27
+
28
+ const result = parseFrontmatter(content);
29
+
30
+ expect(result.frontmatter.title).toBe("Hello World");
31
+ expect(result.frontmatter.description).toBe("A test page");
32
+ });
33
+
34
+ it("returns empty frontmatter when none exists", () => {
35
+ const content = "# Just a heading\n\nSome content";
36
+
37
+ const result = parseFrontmatter(content);
38
+
39
+ expect(result.frontmatter).toEqual({});
40
+ expect(result.body).toBe(content);
41
+ });
42
+
43
+ it("handles empty frontmatter", () => {
44
+ const content = `---
45
+ ---
46
+ # Content`;
47
+
48
+ const result = parseFrontmatter(content);
49
+
50
+ expect(result.frontmatter).toEqual({});
51
+ expect(result.body).toBe("# Content");
52
+ });
53
+ });
54
+
55
+ describe("extractHeadings", () => {
56
+ it("extracts h2 and h3 headings", () => {
57
+ const content = `# Title (ignored)
58
+
59
+ ## Getting Started
60
+
61
+ Some text
62
+
63
+ ### Installation
64
+
65
+ More text
66
+
67
+ ## Configuration
68
+
69
+ ### Options`;
70
+
71
+ const headings = extractHeadings(content);
72
+
73
+ expect(headings).toHaveLength(4);
74
+ expect(headings[0]).toEqual({
75
+ depth: 2,
76
+ text: "Getting Started",
77
+ slug: "getting-started",
78
+ });
79
+ expect(headings[1]).toEqual({
80
+ depth: 3,
81
+ text: "Installation",
82
+ slug: "installation",
83
+ });
84
+ expect(headings[2]).toEqual({
85
+ depth: 2,
86
+ text: "Configuration",
87
+ slug: "configuration",
88
+ });
89
+ expect(headings[3]).toEqual({ depth: 3, text: "Options", slug: "options" });
90
+ });
91
+
92
+ it("generates slugs correctly", () => {
93
+ const content = `## Hello World!
94
+ ## API Reference (v2)
95
+ ## Some--weird---slug`;
96
+
97
+ const headings = extractHeadings(content);
98
+
99
+ expect(headings[0].slug).toBe("hello-world");
100
+ expect(headings[1].slug).toBe("api-reference-v2");
101
+ expect(headings[2].slug).toBe("some-weird-slug");
102
+ });
103
+
104
+ it("returns empty array for content without headings", () => {
105
+ const content = "Just some text without any headings";
106
+
107
+ const headings = extractHeadings(content);
108
+
109
+ expect(headings).toEqual([]);
110
+ });
111
+
112
+ it("extracts h4, h5, h6 headings", () => {
113
+ const content = `#### Level 4
114
+ ##### Level 5
115
+ ###### Level 6`;
116
+
117
+ const headings = extractHeadings(content);
118
+
119
+ expect(headings).toHaveLength(3);
120
+ expect(headings[0].depth).toBe(4);
121
+ expect(headings[1].depth).toBe(5);
122
+ expect(headings[2].depth).toBe(6);
123
+ });
124
+ });
@@ -0,0 +1,205 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ isSnippetPath,
4
+ extractSlugFromPath,
5
+ filterPathsToSlugs,
6
+ } from "../mdx-utils.js";
7
+
8
+ describe("isSnippetPath", () => {
9
+ it("returns true for paths containing /snippets/", () => {
10
+ expect(isSnippetPath("path/to/snippets/file.mdx", ["snippets"])).toBe(true);
11
+ expect(isSnippetPath("/snippets/simple-text.mdx", ["snippets"])).toBe(true);
12
+ expect(isSnippetPath("docs/snippets/test.mdx", ["snippets"])).toBe(true);
13
+ });
14
+
15
+ it("returns true for paths starting with snippet folder", () => {
16
+ expect(isSnippetPath("snippets/file.mdx", ["snippets"])).toBe(true);
17
+ expect(isSnippetPath("snippets/nested/file.mdx", ["snippets"])).toBe(true);
18
+ });
19
+
20
+ it("returns false for non-snippet paths", () => {
21
+ expect(isSnippetPath("docs/introduction.mdx", ["snippets"])).toBe(false);
22
+ expect(isSnippetPath("guides/getting-started.mdx", ["snippets"])).toBe(
23
+ false
24
+ );
25
+ expect(isSnippetPath("api/reference.mdx", ["snippets"])).toBe(false);
26
+ });
27
+
28
+ it("returns true for custom snippet folders", () => {
29
+ expect(isSnippetPath("path/to/shared/file.mdx", ["shared"])).toBe(true);
30
+ expect(isSnippetPath("partials/header.mdx", ["partials"])).toBe(true);
31
+ expect(isSnippetPath("includes/footer.mdx", ["includes"])).toBe(true);
32
+ });
33
+
34
+ it("returns true for multiple custom snippet folders", () => {
35
+ const folders = ["snippets", "shared", "partials"];
36
+ expect(isSnippetPath("snippets/test.mdx", folders)).toBe(true);
37
+ expect(isSnippetPath("shared/test.mdx", folders)).toBe(true);
38
+ expect(isSnippetPath("partials/test.mdx", folders)).toBe(true);
39
+ expect(isSnippetPath("docs/test.mdx", folders)).toBe(false);
40
+ });
41
+
42
+ it("does not match partial folder names", () => {
43
+ expect(isSnippetPath("snippetsextra/file.mdx", ["snippets"])).toBe(false);
44
+ expect(isSnippetPath("mysnippets/file.mdx", ["snippets"])).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe("extractSlugFromPath", () => {
49
+ describe("with @content/ prefix", () => {
50
+ it("returns null for paths in /snippets/ folder", () => {
51
+ expect(
52
+ extractSlugFromPath("@content/snippets/simple-text.mdx", ["snippets"])
53
+ ).toBeNull();
54
+ expect(
55
+ extractSlugFromPath("@content/snippets/nested/file.mdx", ["snippets"])
56
+ ).toBeNull();
57
+ });
58
+
59
+ it("returns null for custom snippet folder paths", () => {
60
+ expect(
61
+ extractSlugFromPath("@content/shared/component.mdx", ["shared"])
62
+ ).toBeNull();
63
+ expect(
64
+ extractSlugFromPath("@content/partials/header.mdx", ["partials"])
65
+ ).toBeNull();
66
+ });
67
+
68
+ it("returns valid slug for non-snippet paths", () => {
69
+ expect(
70
+ extractSlugFromPath("@content/introduction.mdx", ["snippets"])
71
+ ).toBe("introduction");
72
+ expect(
73
+ extractSlugFromPath("@content/guides/getting-started.mdx", ["snippets"])
74
+ ).toBe("guides/getting-started");
75
+ expect(
76
+ extractSlugFromPath("@content/api/reference.md", ["snippets"])
77
+ ).toBe("api/reference");
78
+ });
79
+ });
80
+
81
+ describe("with relative paths (Vite glob format)", () => {
82
+ it("returns null for relative paths containing snippet folder", () => {
83
+ expect(
84
+ extractSlugFromPath(
85
+ "../../../../test-docs/snippets/file.mdx",
86
+ ["snippets"]
87
+ )
88
+ ).toBeNull();
89
+ expect(
90
+ extractSlugFromPath("../../../project/snippets/test.mdx", ["snippets"])
91
+ ).toBeNull();
92
+ });
93
+
94
+ it("returns null for custom snippet folder in relative paths", () => {
95
+ expect(
96
+ extractSlugFromPath("../../../../test-docs/shared/file.mdx", ["shared"])
97
+ ).toBeNull();
98
+ });
99
+
100
+ it("extracts slug from relative paths preserving directory structure", () => {
101
+ expect(
102
+ extractSlugFromPath(
103
+ "../../../../test-docs/guides/using-snippets.mdx",
104
+ ["snippets"]
105
+ )
106
+ ).toBe("guides/using-snippets");
107
+ expect(
108
+ extractSlugFromPath("../../../../test-docs/introduction.mdx", [
109
+ "snippets",
110
+ ])
111
+ ).toBe("introduction");
112
+ expect(
113
+ extractSlugFromPath(
114
+ "../../../../test-docs/api/v2/reference.mdx",
115
+ ["snippets"]
116
+ )
117
+ ).toBe("api/v2/reference");
118
+ });
119
+ });
120
+
121
+ describe("fallback behavior", () => {
122
+ it("extracts slug from paths with absolute-like format", () => {
123
+ expect(extractSlugFromPath("/docs/introduction.mdx", ["snippets"])).toBe(
124
+ "introduction"
125
+ );
126
+ });
127
+
128
+ it("removes extension for simple paths", () => {
129
+ expect(extractSlugFromPath("simple.mdx", ["snippets"])).toBe("simple");
130
+ expect(extractSlugFromPath("simple.md", ["snippets"])).toBe("simple");
131
+ });
132
+ });
133
+ });
134
+
135
+ describe("filterPathsToSlugs", () => {
136
+ it("excludes all snippet folder paths from result", () => {
137
+ const paths = [
138
+ "@content/introduction.mdx",
139
+ "@content/snippets/simple-text.mdx",
140
+ "@content/guides/getting-started.mdx",
141
+ "@content/snippets/with-props.mdx",
142
+ "@content/api/reference.mdx",
143
+ ];
144
+
145
+ const slugs = filterPathsToSlugs(paths, ["snippets"]);
146
+
147
+ expect(slugs).toEqual([
148
+ "introduction",
149
+ "guides/getting-started",
150
+ "api/reference",
151
+ ]);
152
+ expect(slugs).not.toContain("snippets/simple-text");
153
+ expect(slugs).not.toContain("snippets/with-props");
154
+ });
155
+
156
+ it("excludes multiple custom snippet folders", () => {
157
+ const paths = [
158
+ "@content/docs/intro.mdx",
159
+ "@content/snippets/a.mdx",
160
+ "@content/shared/b.mdx",
161
+ "@content/partials/c.mdx",
162
+ "@content/guides/setup.mdx",
163
+ ];
164
+
165
+ const slugs = filterPathsToSlugs(paths, ["snippets", "shared", "partials"]);
166
+
167
+ expect(slugs).toEqual(["docs/intro", "guides/setup"]);
168
+ });
169
+
170
+ it("handles relative paths and excludes snippet folders", () => {
171
+ const paths = [
172
+ "../../../../test-docs/introduction.mdx",
173
+ "../../../../test-docs/snippets/test.mdx",
174
+ "../../../../test-docs/guides/usage.mdx",
175
+ ];
176
+
177
+ const slugs = filterPathsToSlugs(paths, ["snippets"]);
178
+
179
+ expect(slugs).toEqual(["introduction", "guides/usage"]);
180
+ });
181
+
182
+ it("avoids duplicate slugs", () => {
183
+ const paths = [
184
+ "@content/intro.mdx",
185
+ "@content/intro.md",
186
+ "@content/guide.mdx",
187
+ ];
188
+
189
+ const slugs = filterPathsToSlugs(paths, ["snippets"]);
190
+
191
+ expect(slugs).toEqual(["intro", "guide"]);
192
+ });
193
+
194
+ it("returns empty array when all paths are snippets", () => {
195
+ const paths = [
196
+ "@content/snippets/a.mdx",
197
+ "@content/snippets/b.mdx",
198
+ "@content/snippets/c.mdx",
199
+ ];
200
+
201
+ const slugs = filterPathsToSlugs(paths, ["snippets"]);
202
+
203
+ expect(slugs).toEqual([]);
204
+ });
205
+ });