specra 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.MD +21 -0
- package/README.md +157 -0
- package/dist/app/api/mdx-watch/route.d.mts +12 -0
- package/dist/app/api/mdx-watch/route.d.ts +12 -0
- package/dist/app/api/mdx-watch/route.js +98 -0
- package/dist/app/api/mdx-watch/route.js.map +1 -0
- package/dist/app/api/mdx-watch/route.mjs +71 -0
- package/dist/app/api/mdx-watch/route.mjs.map +1 -0
- package/dist/app/docs-page.d.mts +32 -0
- package/dist/app/docs-page.d.ts +32 -0
- package/dist/app/docs-page.js +4072 -0
- package/dist/app/docs-page.js.map +1 -0
- package/dist/app/docs-page.mjs +14 -0
- package/dist/app/docs-page.mjs.map +1 -0
- package/dist/app/layout.css +297 -0
- package/dist/app/layout.css.map +1 -0
- package/dist/app/layout.d.mts +19 -0
- package/dist/app/layout.d.ts +19 -0
- package/dist/app/layout.js +112 -0
- package/dist/app/layout.js.map +1 -0
- package/dist/app/layout.mjs +13 -0
- package/dist/app/layout.mjs.map +1 -0
- package/dist/chunk-DR4EPLMT.mjs +1013 -0
- package/dist/chunk-DR4EPLMT.mjs.map +1 -0
- package/dist/chunk-INL2EC72.mjs +170 -0
- package/dist/chunk-INL2EC72.mjs.map +1 -0
- package/dist/chunk-IZFGEAD6.mjs +61 -0
- package/dist/chunk-IZFGEAD6.mjs.map +1 -0
- package/dist/chunk-KTRWWAGL.mjs +50 -0
- package/dist/chunk-KTRWWAGL.mjs.map +1 -0
- package/dist/chunk-MZJHJ6BV.mjs +21 -0
- package/dist/chunk-MZJHJ6BV.mjs.map +1 -0
- package/dist/chunk-NXRIAL7T.mjs +3119 -0
- package/dist/chunk-NXRIAL7T.mjs.map +1 -0
- package/dist/components/index.d.mts +822 -0
- package/dist/components/index.d.ts +822 -0
- package/dist/components/index.js +3738 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/index.mjs +3627 -0
- package/dist/components/index.mjs.map +1 -0
- package/dist/index.css +297 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.mts +545 -0
- package/dist/index.d.ts +545 -0
- package/dist/index.js +4648 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +347 -0
- package/dist/index.mjs.map +1 -0
- package/dist/lib/index.d.mts +798 -0
- package/dist/lib/index.d.ts +798 -0
- package/dist/lib/index.js +1301 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/index.mjs +89 -0
- package/dist/lib/index.mjs.map +1 -0
- package/package.json +119 -0
- package/src/app/api/mdx-watch/route.ts +86 -0
- package/src/app/docs-page.tsx +212 -0
- package/src/app/layout.tsx +74 -0
- package/src/components/docs/accordion.tsx +53 -0
- package/src/components/docs/api/api-endpoint.tsx +59 -0
- package/src/components/docs/api/api-params.tsx +43 -0
- package/src/components/docs/api/api-playground.tsx +233 -0
- package/src/components/docs/api/api-reference.tsx +291 -0
- package/src/components/docs/api/api-response.tsx +48 -0
- package/src/components/docs/api/index.ts +5 -0
- package/src/components/docs/badge.tsx +22 -0
- package/src/components/docs/breadcrumb.tsx +51 -0
- package/src/components/docs/callout.tsx +109 -0
- package/src/components/docs/card.tsx +84 -0
- package/src/components/docs/category-index.tsx +112 -0
- package/src/components/docs/code-block.tsx +129 -0
- package/src/components/docs/columns.tsx +45 -0
- package/src/components/docs/componentTextProps.ts +85 -0
- package/src/components/docs/dev-mode-badge.tsx +35 -0
- package/src/components/docs/doc-layout-wrapper.tsx +54 -0
- package/src/components/docs/doc-layout.tsx +111 -0
- package/src/components/docs/doc-loading.tsx +15 -0
- package/src/components/docs/doc-metadata.tsx +55 -0
- package/src/components/docs/doc-navigation.tsx +62 -0
- package/src/components/docs/doc-tags.tsx +25 -0
- package/src/components/docs/draft-badge.tsx +10 -0
- package/src/components/docs/footer.tsx +47 -0
- package/src/components/docs/frame.tsx +22 -0
- package/src/components/docs/header.tsx +122 -0
- package/src/components/docs/hot-reload-indicator.tsx +77 -0
- package/src/components/docs/icon.tsx +70 -0
- package/src/components/docs/image-card.tsx +95 -0
- package/src/components/docs/image.tsx +73 -0
- package/src/components/docs/index.ts +48 -0
- package/src/components/docs/math.tsx +46 -0
- package/src/components/docs/mdx-components.tsx +166 -0
- package/src/components/docs/mdx-hot-reload.tsx +37 -0
- package/src/components/docs/mermaid.tsx +77 -0
- package/src/components/docs/mobile-doc-layout.tsx +115 -0
- package/src/components/docs/not-found-content.tsx +55 -0
- package/src/components/docs/search-highlight.tsx +127 -0
- package/src/components/docs/search-modal.tsx +223 -0
- package/src/components/docs/sidebar-skeleton.tsx +39 -0
- package/src/components/docs/sidebar.tsx +323 -0
- package/src/components/docs/site-banner.tsx +92 -0
- package/src/components/docs/steps.tsx +29 -0
- package/src/components/docs/tab-context.tsx +28 -0
- package/src/components/docs/tab-groups.tsx +50 -0
- package/src/components/docs/table-of-contents.tsx +104 -0
- package/src/components/docs/tabs.tsx +63 -0
- package/src/components/docs/theme-toggle.tsx +39 -0
- package/src/components/docs/tooltip.tsx +37 -0
- package/src/components/docs/version-switcher.tsx +52 -0
- package/src/components/docs/video.tsx +80 -0
- package/src/components/global/index.ts +3 -0
- package/src/components/global/version-not-found.tsx +26 -0
- package/src/components/index.ts +8 -0
- package/src/components/theme-provider.tsx +11 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/button.tsx +60 -0
- package/src/components/ui/dialog.tsx +143 -0
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/index.ts +41 -0
- package/src/lib/api-parser.types.ts +78 -0
- package/src/lib/api.types.ts +202 -0
- package/src/lib/category.ts +71 -0
- package/src/lib/config.server.ts +170 -0
- package/src/lib/config.ts +20 -0
- package/src/lib/config.types.ts +295 -0
- package/src/lib/dev-utils.ts +75 -0
- package/src/lib/index.ts +27 -0
- package/src/lib/mdx-cache.ts +200 -0
- package/src/lib/mdx.ts +402 -0
- package/src/lib/parsers/base-parser.ts +16 -0
- package/src/lib/parsers/index.ts +69 -0
- package/src/lib/parsers/openapi-parser.ts +251 -0
- package/src/lib/parsers/postman-parser.ts +301 -0
- package/src/lib/parsers/specra-parser.ts +24 -0
- package/src/lib/redirects.ts +40 -0
- package/src/lib/remark-code-meta.ts +23 -0
- package/src/lib/sidebar-utils.ts +188 -0
- package/src/lib/toc.ts +24 -0
- package/src/lib/utils.ts +36 -0
- package/src/specra.config.json +124 -0
- package/src/styles/globals.css +427 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caching layer for MDX operations to improve development performance
|
|
3
|
+
*
|
|
4
|
+
* This module provides in-memory caching for expensive file system operations
|
|
5
|
+
* that occur during static generation. In development mode, caches are
|
|
6
|
+
* invalidated automatically when files change.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Doc, getVersions, getAllDocs, getDocBySlug } from './mdx'
|
|
10
|
+
import { watch } from 'fs'
|
|
11
|
+
import { join } from 'path'
|
|
12
|
+
import { PerfTimer, logCacheOperation } from './dev-utils'
|
|
13
|
+
|
|
14
|
+
const isDevelopment = process.env.NODE_ENV === 'development'
|
|
15
|
+
|
|
16
|
+
// Cache stores
|
|
17
|
+
const versionsCache = {
|
|
18
|
+
data: null as string[] | null,
|
|
19
|
+
timestamp: 0,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const allDocsCache = new Map<string, {
|
|
23
|
+
data: Doc[]
|
|
24
|
+
timestamp: number
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
const docBySlugCache = new Map<string, {
|
|
28
|
+
data: Doc | null
|
|
29
|
+
timestamp: number
|
|
30
|
+
}>()
|
|
31
|
+
|
|
32
|
+
// Cache TTL (time to live) in milliseconds
|
|
33
|
+
const CACHE_TTL = isDevelopment ? 5000 : 60000 // 5s in dev, 60s in prod
|
|
34
|
+
|
|
35
|
+
// Track if we've set up file watchers
|
|
36
|
+
let watchersInitialized = false
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Initialize file watchers to invalidate cache on changes
|
|
40
|
+
* Only runs in development mode
|
|
41
|
+
*/
|
|
42
|
+
function initializeWatchers() {
|
|
43
|
+
if (!isDevelopment || watchersInitialized) return
|
|
44
|
+
|
|
45
|
+
watchersInitialized = true
|
|
46
|
+
const docsPath = join(process.cwd(), 'docs')
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
watch(docsPath, { recursive: true }, (eventType, filename) => {
|
|
50
|
+
if (!filename) return
|
|
51
|
+
|
|
52
|
+
// Invalidate relevant caches when MDX or JSON files change
|
|
53
|
+
if (filename.endsWith('.mdx') || filename.endsWith('.json')) {
|
|
54
|
+
// Extract version from path
|
|
55
|
+
const parts = filename.split(/[/\\]/)
|
|
56
|
+
const version = parts[0]
|
|
57
|
+
|
|
58
|
+
// Clear all docs cache for this version
|
|
59
|
+
allDocsCache.delete(version)
|
|
60
|
+
|
|
61
|
+
// Clear individual doc caches for this version
|
|
62
|
+
const cacheKeysToDelete: string[] = []
|
|
63
|
+
docBySlugCache.forEach((_, key) => {
|
|
64
|
+
if (key.startsWith(`${version}:`)) {
|
|
65
|
+
cacheKeysToDelete.push(key)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
cacheKeysToDelete.forEach(key => docBySlugCache.delete(key))
|
|
69
|
+
|
|
70
|
+
// Clear versions cache if directory structure changed
|
|
71
|
+
if (eventType === 'rename') {
|
|
72
|
+
versionsCache.data = null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(`[MDX Cache] Invalidated cache for: ${filename}`)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
console.log('[MDX Cache] File watchers initialized')
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error('[MDX Cache] Failed to initialize watchers:', error)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if a cache entry is still valid
|
|
87
|
+
*/
|
|
88
|
+
function isCacheValid(timestamp: number): boolean {
|
|
89
|
+
return Date.now() - timestamp < CACHE_TTL
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Cached version of getVersions()
|
|
94
|
+
*/
|
|
95
|
+
export function getCachedVersions(): string[] {
|
|
96
|
+
// Initialize watchers on first use
|
|
97
|
+
initializeWatchers()
|
|
98
|
+
|
|
99
|
+
if (versionsCache.data && isCacheValid(versionsCache.timestamp)) {
|
|
100
|
+
logCacheOperation('hit', 'versions')
|
|
101
|
+
return versionsCache.data
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
logCacheOperation('miss', 'versions')
|
|
105
|
+
const timer = new PerfTimer('getVersions')
|
|
106
|
+
const versions = getVersions()
|
|
107
|
+
timer.end()
|
|
108
|
+
|
|
109
|
+
versionsCache.data = versions
|
|
110
|
+
versionsCache.timestamp = Date.now()
|
|
111
|
+
|
|
112
|
+
return versions
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Cached version of getAllDocs()
|
|
117
|
+
*/
|
|
118
|
+
export async function getCachedAllDocs(version = 'v1.0.0'): Promise<Doc[]> {
|
|
119
|
+
// Initialize watchers on first use
|
|
120
|
+
initializeWatchers()
|
|
121
|
+
|
|
122
|
+
const cached = allDocsCache.get(version)
|
|
123
|
+
if (cached && isCacheValid(cached.timestamp)) {
|
|
124
|
+
logCacheOperation('hit', `getAllDocs:${version}`)
|
|
125
|
+
return cached.data
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
logCacheOperation('miss', `getAllDocs:${version}`)
|
|
129
|
+
const timer = new PerfTimer(`getAllDocs(${version})`)
|
|
130
|
+
const docs = await getAllDocs(version)
|
|
131
|
+
timer.end()
|
|
132
|
+
|
|
133
|
+
allDocsCache.set(version, {
|
|
134
|
+
data: docs,
|
|
135
|
+
timestamp: Date.now(),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return docs
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Cached version of getDocBySlug()
|
|
143
|
+
*/
|
|
144
|
+
export async function getCachedDocBySlug(
|
|
145
|
+
slug: string,
|
|
146
|
+
version = 'v1.0.0'
|
|
147
|
+
): Promise<Doc | null> {
|
|
148
|
+
// Initialize watchers on first use
|
|
149
|
+
initializeWatchers()
|
|
150
|
+
|
|
151
|
+
const cacheKey = `${version}:${slug}`
|
|
152
|
+
const cached = docBySlugCache.get(cacheKey)
|
|
153
|
+
|
|
154
|
+
if (cached && isCacheValid(cached.timestamp)) {
|
|
155
|
+
logCacheOperation('hit', `getDocBySlug:${cacheKey}`)
|
|
156
|
+
return cached.data
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
logCacheOperation('miss', `getDocBySlug:${cacheKey}`)
|
|
160
|
+
const timer = new PerfTimer(`getDocBySlug(${slug})`)
|
|
161
|
+
const doc = await getDocBySlug(slug, version)
|
|
162
|
+
timer.end()
|
|
163
|
+
|
|
164
|
+
docBySlugCache.set(cacheKey, {
|
|
165
|
+
data: doc,
|
|
166
|
+
timestamp: Date.now(),
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
return doc
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Manually clear all caches
|
|
174
|
+
* Useful for testing or when you want to force a refresh
|
|
175
|
+
*/
|
|
176
|
+
export function clearAllCaches() {
|
|
177
|
+
versionsCache.data = null
|
|
178
|
+
allDocsCache.clear()
|
|
179
|
+
docBySlugCache.clear()
|
|
180
|
+
console.log('[MDX Cache] All caches cleared')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get cache statistics for debugging
|
|
185
|
+
*/
|
|
186
|
+
export function getCacheStats() {
|
|
187
|
+
return {
|
|
188
|
+
versions: {
|
|
189
|
+
cached: versionsCache.data !== null,
|
|
190
|
+
age: versionsCache.timestamp ? Date.now() - versionsCache.timestamp : 0,
|
|
191
|
+
},
|
|
192
|
+
allDocs: {
|
|
193
|
+
entries: allDocsCache.size,
|
|
194
|
+
versions: Array.from(allDocsCache.keys()),
|
|
195
|
+
},
|
|
196
|
+
docBySlug: {
|
|
197
|
+
entries: docBySlugCache.size,
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/lib/mdx.ts
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import matter from "gray-matter"
|
|
4
|
+
import { getAllCategoryConfigs } from "./category"
|
|
5
|
+
import { sortSidebarItems, sortSidebarGroups, buildSidebarStructure, type SidebarGroup } from "./sidebar-utils"
|
|
6
|
+
|
|
7
|
+
const DOCS_DIR = path.join(process.cwd(), "docs")
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Calculate reading time based on word count
|
|
11
|
+
* Average reading speed: 200 words per minute
|
|
12
|
+
*/
|
|
13
|
+
function calculateReadingTime(content: string): { minutes: number; words: number } {
|
|
14
|
+
const words = content.trim().split(/\s+/).length
|
|
15
|
+
const minutes = Math.ceil(words / 200)
|
|
16
|
+
return { minutes, words }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DocMeta {
|
|
20
|
+
title: string
|
|
21
|
+
description?: string
|
|
22
|
+
slug?: string
|
|
23
|
+
section?: string
|
|
24
|
+
group?: string
|
|
25
|
+
sidebar?: string
|
|
26
|
+
order?: number
|
|
27
|
+
sidebar_position?: number
|
|
28
|
+
content?: string
|
|
29
|
+
last_updated?: string
|
|
30
|
+
draft?: boolean
|
|
31
|
+
authors?: Array<{ id: string; name?: string }>
|
|
32
|
+
tags?: string[]
|
|
33
|
+
redirect_from?: string[]
|
|
34
|
+
reading_time?: number
|
|
35
|
+
word_count?: number
|
|
36
|
+
icon?: string // Icon name for sidebar display (Lucide icon name)
|
|
37
|
+
tab_group?: string // Tab group ID for organizing docs into tabs
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Doc {
|
|
41
|
+
slug: string
|
|
42
|
+
filePath: string // Original file path for sidebar grouping
|
|
43
|
+
title: string
|
|
44
|
+
meta: DocMeta
|
|
45
|
+
content: string
|
|
46
|
+
categoryLabel?: string // Label from _category_.json
|
|
47
|
+
categoryPosition?: number // Position from _category_.json
|
|
48
|
+
categoryCollapsible?: boolean // Collapsible from _category_.json
|
|
49
|
+
categoryCollapsed?: boolean // Default collapsed state from _category_.json
|
|
50
|
+
categoryIcon?: string // Icon from _category_.json
|
|
51
|
+
categoryTabGroup?: string // Tab group from _category_.json
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TocItem {
|
|
55
|
+
id: string
|
|
56
|
+
title: string
|
|
57
|
+
level: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getVersions(): string[] {
|
|
61
|
+
try {
|
|
62
|
+
const versions = fs.readdirSync(DOCS_DIR)
|
|
63
|
+
return versions.filter((v) => fs.statSync(path.join(DOCS_DIR, v)).isDirectory())
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return ["v1.0.0"]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Recursively find all MDX files in a directory
|
|
71
|
+
*/
|
|
72
|
+
function findMdxFiles(dir: string, baseDir: string = dir): string[] {
|
|
73
|
+
const files: string[] = []
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
77
|
+
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const fullPath = path.join(dir, entry.name)
|
|
80
|
+
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
files.push(...findMdxFiles(fullPath, baseDir))
|
|
83
|
+
} else if (entry.isFile() && entry.name.endsWith(".mdx")) {
|
|
84
|
+
// Get relative path from base directory and normalize to forward slashes
|
|
85
|
+
const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/')
|
|
86
|
+
files.push(relativePath)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(`Error reading directory ${dir}:`, error)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return files
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Internal function to read a doc from file path
|
|
98
|
+
*/
|
|
99
|
+
function readDocFromFile(filePath: string, originalSlug: string): Doc | null {
|
|
100
|
+
try {
|
|
101
|
+
if (!fs.existsSync(filePath)) {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const fileContents = fs.readFileSync(filePath, "utf8")
|
|
106
|
+
const { data, content } = matter(fileContents)
|
|
107
|
+
|
|
108
|
+
// Calculate reading time
|
|
109
|
+
const { minutes, words } = calculateReadingTime(content)
|
|
110
|
+
|
|
111
|
+
// If custom slug provided, replace only the filename part, keep the folder structure
|
|
112
|
+
let finalSlug = originalSlug
|
|
113
|
+
if (data.slug) {
|
|
114
|
+
const customSlug = data.slug.replace(/^\//, '')
|
|
115
|
+
const parts = originalSlug.split("/")
|
|
116
|
+
|
|
117
|
+
if (parts.length > 1) {
|
|
118
|
+
// Keep folder structure, replace only filename
|
|
119
|
+
parts[parts.length - 1] = customSlug
|
|
120
|
+
finalSlug = parts.join("/")
|
|
121
|
+
} else {
|
|
122
|
+
// Root level file, use custom slug as-is
|
|
123
|
+
finalSlug = customSlug
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
slug: finalSlug,
|
|
129
|
+
filePath: originalSlug, // Keep original file path for sidebar
|
|
130
|
+
title: data.title || originalSlug,
|
|
131
|
+
meta: {
|
|
132
|
+
...data,
|
|
133
|
+
content,
|
|
134
|
+
reading_time: minutes,
|
|
135
|
+
word_count: words,
|
|
136
|
+
} as DocMeta,
|
|
137
|
+
content,
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error(`Error reading file ${filePath}:`, error)
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function getDocBySlug(slug: string, version = "v1.0.0"): Promise<Doc | null> {
|
|
146
|
+
try {
|
|
147
|
+
// Try direct file first
|
|
148
|
+
let filePath = path.join(DOCS_DIR, version, `${slug}.mdx`)
|
|
149
|
+
let doc = readDocFromFile(filePath, slug)
|
|
150
|
+
|
|
151
|
+
if (doc) return doc
|
|
152
|
+
|
|
153
|
+
// If not found, try index.mdx in the folder
|
|
154
|
+
filePath = path.join(DOCS_DIR, version, slug, "index.mdx")
|
|
155
|
+
doc = readDocFromFile(filePath, slug)
|
|
156
|
+
|
|
157
|
+
if (doc) return doc
|
|
158
|
+
|
|
159
|
+
// If still not found, search all docs for a matching custom slug
|
|
160
|
+
const versionDir = path.join(DOCS_DIR, version)
|
|
161
|
+
if (!fs.existsSync(versionDir)) {
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const mdxFiles = findMdxFiles(versionDir)
|
|
166
|
+
|
|
167
|
+
for (const file of mdxFiles) {
|
|
168
|
+
const fileSlug = file.replace(/\.mdx$/, "")
|
|
169
|
+
const testPath = path.join(versionDir, file.endsWith("index.mdx") ? file : `${fileSlug}.mdx`)
|
|
170
|
+
const testDoc = readDocFromFile(testPath, fileSlug)
|
|
171
|
+
|
|
172
|
+
if (testDoc && testDoc.slug === slug) {
|
|
173
|
+
return testDoc
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return null
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error(`Error reading doc ${slug}:`, error)
|
|
180
|
+
return null
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function getAllDocs(version = "v1.0.0"): Promise<Doc[]> {
|
|
185
|
+
try {
|
|
186
|
+
const versionDir = path.join(DOCS_DIR, version)
|
|
187
|
+
|
|
188
|
+
if (!fs.existsSync(versionDir)) {
|
|
189
|
+
return []
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const mdxFiles = findMdxFiles(versionDir)
|
|
193
|
+
const categoryConfigs = getAllCategoryConfigs(version)
|
|
194
|
+
|
|
195
|
+
const docs = await Promise.all(
|
|
196
|
+
mdxFiles.map(async (file) => {
|
|
197
|
+
const originalFilePath = file.replace(/\.mdx$/, "")
|
|
198
|
+
let slug = originalFilePath
|
|
199
|
+
|
|
200
|
+
// If this is an index.mdx, use the directory path as the slug
|
|
201
|
+
if (file.endsWith("/index.mdx") || file === "index.mdx") {
|
|
202
|
+
slug = path.dirname(file).replace(/\\/g, '/')
|
|
203
|
+
if (slug === ".") slug = "" // Root index
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const doc = await getDocBySlug(slug, version)
|
|
207
|
+
|
|
208
|
+
// Override filePath to preserve the original file structure
|
|
209
|
+
if (doc) {
|
|
210
|
+
doc.filePath = originalFilePath
|
|
211
|
+
|
|
212
|
+
// Apply category config if exists
|
|
213
|
+
const folderPath = path.dirname(originalFilePath).replace(/\\/g, '/')
|
|
214
|
+
if (folderPath !== ".") {
|
|
215
|
+
const categoryConfig = categoryConfigs.get(folderPath)
|
|
216
|
+
if (categoryConfig) {
|
|
217
|
+
doc.categoryLabel = categoryConfig.label
|
|
218
|
+
// Use position if available, otherwise fall back to sidebar_position
|
|
219
|
+
doc.categoryPosition = categoryConfig.position ?? categoryConfig.sidebar_position
|
|
220
|
+
doc.categoryCollapsible = categoryConfig.collapsible
|
|
221
|
+
doc.categoryCollapsed = categoryConfig.collapsed
|
|
222
|
+
doc.categoryIcon = categoryConfig.icon
|
|
223
|
+
doc.categoryTabGroup = categoryConfig.tab_group
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return doc
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
const isDevelopment = process.env.NODE_ENV === "development"
|
|
233
|
+
|
|
234
|
+
// Create a map to track unique slugs and avoid duplicates
|
|
235
|
+
const uniqueDocs = new Map<string, Doc>()
|
|
236
|
+
|
|
237
|
+
docs
|
|
238
|
+
.filter((doc): doc is Doc => doc !== null)
|
|
239
|
+
// Filter out drafts in production
|
|
240
|
+
.filter((doc) => isDevelopment || !doc.meta.draft)
|
|
241
|
+
.forEach((doc) => {
|
|
242
|
+
// Use the doc's slug (which may be custom from frontmatter) as the key
|
|
243
|
+
uniqueDocs.set(doc.slug, doc)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
return Array.from(uniqueDocs.values()).sort((a, b) => {
|
|
247
|
+
const orderA = a.meta.sidebar_position ?? a.meta.order ?? 999
|
|
248
|
+
const orderB = b.meta.sidebar_position ?? b.meta.order ?? 999
|
|
249
|
+
return orderA - orderB
|
|
250
|
+
})
|
|
251
|
+
} catch (error) {
|
|
252
|
+
console.error(`Error getting all docs for version ${version}:`, error)
|
|
253
|
+
return []
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// export function getAdjacentDocs(currentSlug: string, allDocs: Doc[]): { previous?: Doc; next?: Doc } {
|
|
258
|
+
// const currentIndex = allDocs.findIndex((doc) => doc.slug === currentSlug)
|
|
259
|
+
|
|
260
|
+
// if (currentIndex === -1) {
|
|
261
|
+
// return {}
|
|
262
|
+
// }
|
|
263
|
+
|
|
264
|
+
// return {
|
|
265
|
+
// previous: currentIndex > 0 ? allDocs[currentIndex - 1] : undefined,
|
|
266
|
+
// next: currentIndex < allDocs.length - 1 ? allDocs[currentIndex + 1] : undefined,
|
|
267
|
+
// }
|
|
268
|
+
// }
|
|
269
|
+
|
|
270
|
+
// Flatten the sidebar structure into a linear order
|
|
271
|
+
function flattenSidebarOrder(
|
|
272
|
+
rootGroups: Record<string, SidebarGroup>,
|
|
273
|
+
standalone: Doc[]
|
|
274
|
+
): Doc[] {
|
|
275
|
+
const flatDocs: Doc[] = []
|
|
276
|
+
|
|
277
|
+
// Recursively flatten groups - intermix folders and files by position
|
|
278
|
+
const flattenGroup = (group: SidebarGroup) => {
|
|
279
|
+
const sortedChildren = sortSidebarGroups(group.children)
|
|
280
|
+
const sortedItems = sortSidebarItems(group.items)
|
|
281
|
+
|
|
282
|
+
// Merge child groups and items, then sort by position
|
|
283
|
+
const merged: Array<{type: 'group', group: SidebarGroup, position: number} | {type: 'item', doc: Doc, position: number}> = [
|
|
284
|
+
...sortedChildren.map(([, childGroup]) => ({
|
|
285
|
+
type: 'group' as const,
|
|
286
|
+
group: childGroup,
|
|
287
|
+
position: childGroup.position
|
|
288
|
+
})),
|
|
289
|
+
...sortedItems.map((doc) => ({
|
|
290
|
+
type: 'item' as const,
|
|
291
|
+
doc,
|
|
292
|
+
position: doc.meta.sidebar_position ?? doc.meta.order ?? 999
|
|
293
|
+
}))
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
// Sort by position
|
|
297
|
+
merged.sort((a, b) => a.position - b.position)
|
|
298
|
+
|
|
299
|
+
// Process in sorted order
|
|
300
|
+
merged.forEach((item) => {
|
|
301
|
+
if (item.type === 'group') {
|
|
302
|
+
flattenGroup(item.group)
|
|
303
|
+
} else {
|
|
304
|
+
flatDocs.push(item.doc)
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Add standalone items first
|
|
310
|
+
sortSidebarItems(standalone).forEach((doc) => {
|
|
311
|
+
flatDocs.push(doc)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// Then add all grouped items
|
|
315
|
+
const sortedRootGroups = sortSidebarGroups(rootGroups)
|
|
316
|
+
sortedRootGroups.forEach(([, group]) => {
|
|
317
|
+
flattenGroup(group)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
return flatDocs
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function getAdjacentDocs(currentSlug: string, allDocs: Doc[]): { previous?: Doc; next?: Doc } {
|
|
324
|
+
// Build the same sidebar structure
|
|
325
|
+
const { rootGroups, standalone } = buildSidebarStructure(allDocs)
|
|
326
|
+
|
|
327
|
+
// Flatten into the same order as shown in the sidebar
|
|
328
|
+
const orderedDocs = flattenSidebarOrder(rootGroups, standalone)
|
|
329
|
+
|
|
330
|
+
// Find current doc in the ordered list
|
|
331
|
+
const currentIndex = orderedDocs.findIndex((doc) => doc.slug === currentSlug)
|
|
332
|
+
|
|
333
|
+
if (currentIndex === -1) {
|
|
334
|
+
return {}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const currentDoc = orderedDocs[currentIndex]
|
|
338
|
+
|
|
339
|
+
// Get current doc's tab group (from meta or category)
|
|
340
|
+
const currentTabGroup = currentDoc.meta?.tab_group || currentDoc.categoryTabGroup
|
|
341
|
+
|
|
342
|
+
// Filter docs to match the current doc's tab group status
|
|
343
|
+
// If current has a tab group, only show docs in the same tab group
|
|
344
|
+
// If current has NO tab group, only show docs with NO tab group
|
|
345
|
+
const filteredDocs = orderedDocs.filter((doc) => {
|
|
346
|
+
const docTabGroup = doc.meta?.tab_group || doc.categoryTabGroup
|
|
347
|
+
|
|
348
|
+
// If current doc has a tab group, only include docs with the same tab group
|
|
349
|
+
if (currentTabGroup) {
|
|
350
|
+
return docTabGroup === currentTabGroup
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// If current doc has no tab group, only include docs with no tab group
|
|
354
|
+
return !docTabGroup
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
// Find current doc's index within the filtered list
|
|
358
|
+
const filteredIndex = filteredDocs.findIndex((doc) => doc.slug === currentSlug)
|
|
359
|
+
|
|
360
|
+
if (filteredIndex === -1) {
|
|
361
|
+
return {}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
previous: filteredIndex > 0 ? filteredDocs[filteredIndex - 1] : undefined,
|
|
366
|
+
next: filteredIndex < filteredDocs.length - 1 ? filteredDocs[filteredIndex + 1] : undefined,
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function extractTableOfContents(content: string): TocItem[] {
|
|
371
|
+
const headingRegex = /^(#{2,3})\s+(.+)$/gm
|
|
372
|
+
const toc: TocItem[] = []
|
|
373
|
+
let match
|
|
374
|
+
|
|
375
|
+
while ((match = headingRegex.exec(content)) !== null) {
|
|
376
|
+
const level = match[1].length
|
|
377
|
+
const text = match[2]
|
|
378
|
+
// Generate ID the same way rehype-slug does
|
|
379
|
+
const id = text
|
|
380
|
+
.toLowerCase()
|
|
381
|
+
.replace(/\s+/g, "-") // Replace spaces with hyphens first
|
|
382
|
+
.replace(/[^a-z0-9-]/g, "") // Remove special chars (dots, slashes, etc)
|
|
383
|
+
.replace(/^-|-$/g, "") // Remove leading/trailing hyphens
|
|
384
|
+
|
|
385
|
+
toc.push({ id, title: text, level })
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return toc
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Check if a slug represents a category (has child documents)
|
|
393
|
+
*/
|
|
394
|
+
export function isCategoryPage(slug: string, allDocs: Doc[]): boolean {
|
|
395
|
+
return allDocs.some((doc) => {
|
|
396
|
+
const parts = doc.slug.split("/")
|
|
397
|
+
const docParent = parts.slice(0, -1).join("/")
|
|
398
|
+
return docParent === slug && doc.slug !== slug
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SpecraApiSpec } from "../api-parser.types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base interface for all API spec parsers
|
|
5
|
+
*/
|
|
6
|
+
export interface ApiSpecParser {
|
|
7
|
+
/**
|
|
8
|
+
* Parse the input spec and convert to Specra format
|
|
9
|
+
*/
|
|
10
|
+
parse(input: any): SpecraApiSpec
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validate if the input is in the expected format
|
|
14
|
+
*/
|
|
15
|
+
validate(input: any): boolean
|
|
16
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SpecraApiSpec } from "../api-parser.types"
|
|
2
|
+
import type { ApiSpecParser } from "./base-parser"
|
|
3
|
+
import { SpecraParser } from "./specra-parser"
|
|
4
|
+
import { OpenApiParser } from "./openapi-parser"
|
|
5
|
+
import { PostmanParser } from "./postman-parser"
|
|
6
|
+
|
|
7
|
+
export type ParserType = "auto" | "specra" | "openapi" | "postman"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Registry of all available parsers
|
|
11
|
+
*/
|
|
12
|
+
const parsers: Map<string, ApiSpecParser> = new Map([
|
|
13
|
+
["specra", new SpecraParser()],
|
|
14
|
+
["openapi", new OpenApiParser()],
|
|
15
|
+
["postman", new PostmanParser()],
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Auto-detect the parser type based on the input structure
|
|
20
|
+
*/
|
|
21
|
+
export function detectParserType(input: any): ParserType {
|
|
22
|
+
if (!input || typeof input !== "object") {
|
|
23
|
+
throw new Error("Invalid API spec: input must be an object")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check for Postman Collection
|
|
27
|
+
if (input.info?.schema?.includes("v2")) {
|
|
28
|
+
return "postman"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Check for OpenAPI/Swagger
|
|
32
|
+
if (input.openapi || input.swagger) {
|
|
33
|
+
return "openapi"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check for Specra format
|
|
37
|
+
if (input.endpoints && Array.isArray(input.endpoints)) {
|
|
38
|
+
return "specra"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw new Error(
|
|
42
|
+
"Unable to auto-detect API spec format. Supported formats: Specra, OpenAPI 3.x, Postman Collection v2.x"
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse an API spec using the specified or auto-detected parser
|
|
48
|
+
*/
|
|
49
|
+
export function parseApiSpec(input: any, parserType: ParserType = "auto"): SpecraApiSpec {
|
|
50
|
+
// Auto-detect if needed
|
|
51
|
+
const actualType = parserType === "auto" ? detectParserType(input) : parserType
|
|
52
|
+
|
|
53
|
+
// Get the parser
|
|
54
|
+
const parser = parsers.get(actualType)
|
|
55
|
+
if (!parser) {
|
|
56
|
+
throw new Error(`Unknown parser type: ${actualType}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Validate and parse
|
|
60
|
+
if (!parser.validate(input)) {
|
|
61
|
+
throw new Error(`Input does not match ${actualType} format`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return parser.parse(input)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Export parsers for direct use
|
|
68
|
+
export { SpecraParser, OpenApiParser, PostmanParser }
|
|
69
|
+
export type { ApiSpecParser }
|