@raystack/chronicle 0.1.0-canary.e11f924

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 (104) hide show
  1. package/bin/chronicle.js +2 -0
  2. package/dist/cli/index.js +9788 -0
  3. package/next.config.mjs +10 -0
  4. package/package.json +62 -0
  5. package/source.config.ts +50 -0
  6. package/src/app/[[...slug]]/layout.tsx +15 -0
  7. package/src/app/[[...slug]]/page.tsx +57 -0
  8. package/src/app/api/apis-proxy/route.ts +59 -0
  9. package/src/app/api/health/route.ts +3 -0
  10. package/src/app/api/search/route.ts +90 -0
  11. package/src/app/apis/[[...slug]]/layout.module.css +22 -0
  12. package/src/app/apis/[[...slug]]/layout.tsx +26 -0
  13. package/src/app/apis/[[...slug]]/page.tsx +57 -0
  14. package/src/app/layout.tsx +26 -0
  15. package/src/app/llms-full.txt/route.ts +18 -0
  16. package/src/app/llms.txt/route.ts +15 -0
  17. package/src/app/providers.tsx +8 -0
  18. package/src/cli/commands/build.ts +33 -0
  19. package/src/cli/commands/dev.ts +34 -0
  20. package/src/cli/commands/init.ts +58 -0
  21. package/src/cli/commands/serve.ts +54 -0
  22. package/src/cli/commands/start.ts +34 -0
  23. package/src/cli/index.ts +21 -0
  24. package/src/cli/utils/config.ts +43 -0
  25. package/src/cli/utils/index.ts +2 -0
  26. package/src/cli/utils/process.ts +7 -0
  27. package/src/components/api/code-snippets.module.css +7 -0
  28. package/src/components/api/code-snippets.tsx +76 -0
  29. package/src/components/api/endpoint-page.module.css +58 -0
  30. package/src/components/api/endpoint-page.tsx +283 -0
  31. package/src/components/api/field-row.module.css +126 -0
  32. package/src/components/api/field-row.tsx +204 -0
  33. package/src/components/api/field-section.module.css +24 -0
  34. package/src/components/api/field-section.tsx +100 -0
  35. package/src/components/api/index.ts +8 -0
  36. package/src/components/api/json-editor.module.css +9 -0
  37. package/src/components/api/json-editor.tsx +61 -0
  38. package/src/components/api/key-value-editor.module.css +13 -0
  39. package/src/components/api/key-value-editor.tsx +62 -0
  40. package/src/components/api/method-badge.module.css +4 -0
  41. package/src/components/api/method-badge.tsx +29 -0
  42. package/src/components/api/response-panel.module.css +8 -0
  43. package/src/components/api/response-panel.tsx +44 -0
  44. package/src/components/common/breadcrumb.tsx +3 -0
  45. package/src/components/common/button.tsx +3 -0
  46. package/src/components/common/callout.module.css +7 -0
  47. package/src/components/common/callout.tsx +27 -0
  48. package/src/components/common/code-block.tsx +3 -0
  49. package/src/components/common/dialog.tsx +3 -0
  50. package/src/components/common/index.ts +10 -0
  51. package/src/components/common/input-field.tsx +3 -0
  52. package/src/components/common/sidebar.tsx +3 -0
  53. package/src/components/common/switch.tsx +3 -0
  54. package/src/components/common/table.tsx +3 -0
  55. package/src/components/common/tabs.tsx +3 -0
  56. package/src/components/mdx/code.module.css +42 -0
  57. package/src/components/mdx/code.tsx +27 -0
  58. package/src/components/mdx/details.module.css +37 -0
  59. package/src/components/mdx/details.tsx +18 -0
  60. package/src/components/mdx/image.tsx +38 -0
  61. package/src/components/mdx/index.tsx +35 -0
  62. package/src/components/mdx/link.tsx +38 -0
  63. package/src/components/mdx/mermaid.module.css +9 -0
  64. package/src/components/mdx/mermaid.tsx +37 -0
  65. package/src/components/mdx/paragraph.module.css +8 -0
  66. package/src/components/mdx/paragraph.tsx +19 -0
  67. package/src/components/mdx/table.tsx +40 -0
  68. package/src/components/ui/breadcrumbs.tsx +72 -0
  69. package/src/components/ui/client-theme-switcher.tsx +18 -0
  70. package/src/components/ui/footer.module.css +27 -0
  71. package/src/components/ui/footer.tsx +30 -0
  72. package/src/components/ui/search.module.css +104 -0
  73. package/src/components/ui/search.tsx +202 -0
  74. package/src/lib/api-routes.ts +120 -0
  75. package/src/lib/config.ts +47 -0
  76. package/src/lib/get-llm-text.ts +10 -0
  77. package/src/lib/index.ts +2 -0
  78. package/src/lib/openapi.ts +188 -0
  79. package/src/lib/remark-unused-directives.ts +30 -0
  80. package/src/lib/schema.ts +99 -0
  81. package/src/lib/snippet-generators.ts +87 -0
  82. package/src/lib/source.ts +67 -0
  83. package/src/themes/default/Layout.module.css +81 -0
  84. package/src/themes/default/Layout.tsx +133 -0
  85. package/src/themes/default/Page.module.css +46 -0
  86. package/src/themes/default/Page.tsx +21 -0
  87. package/src/themes/default/Toc.module.css +48 -0
  88. package/src/themes/default/Toc.tsx +66 -0
  89. package/src/themes/default/font.ts +6 -0
  90. package/src/themes/default/index.ts +13 -0
  91. package/src/themes/paper/ChapterNav.module.css +71 -0
  92. package/src/themes/paper/ChapterNav.tsx +96 -0
  93. package/src/themes/paper/Layout.module.css +33 -0
  94. package/src/themes/paper/Layout.tsx +25 -0
  95. package/src/themes/paper/Page.module.css +174 -0
  96. package/src/themes/paper/Page.tsx +107 -0
  97. package/src/themes/paper/ReadingProgress.module.css +132 -0
  98. package/src/themes/paper/ReadingProgress.tsx +294 -0
  99. package/src/themes/paper/index.ts +8 -0
  100. package/src/themes/registry.ts +14 -0
  101. package/src/types/config.ts +69 -0
  102. package/src/types/content.ts +35 -0
  103. package/src/types/index.ts +3 -0
  104. package/src/types/theme.ts +22 -0
@@ -0,0 +1,104 @@
1
+ .trigger {
2
+ gap: 8px;
3
+ color: var(--rs-color-foreground-base-secondary);
4
+ cursor: pointer;
5
+ }
6
+
7
+ .kbd {
8
+ padding: 2px 6px;
9
+ border-radius: 4px;
10
+ border: 1px solid var(--rs-color-border-base-primary);
11
+ font-size: 12px;
12
+ }
13
+
14
+ .dialogContent {
15
+ max-width: 600px;
16
+ padding: 0;
17
+ min-height: 0;
18
+ position: fixed;
19
+ top: 20%;
20
+ left: 50%;
21
+ transform: translateX(-50%);
22
+ border-radius: var(--rs-radius-4);
23
+ }
24
+
25
+ .input {
26
+ flex: 1;
27
+ border: none;
28
+ outline: none;
29
+ background: transparent;
30
+ font-size: 16px;
31
+ color: var(--rs-color-foreground-base-primary);
32
+ }
33
+
34
+ .list {
35
+ max-height: 300px;
36
+ overflow: auto;
37
+ padding: 0;
38
+ }
39
+
40
+ .visuallyHidden {
41
+ position: absolute;
42
+ width: 1px;
43
+ height: 1px;
44
+ padding: 0;
45
+ margin: -1px;
46
+ overflow: hidden;
47
+ clip: rect(0, 0, 0, 0);
48
+ white-space: nowrap;
49
+ border: 0;
50
+ }
51
+
52
+ .item {
53
+ padding: 12px 16px;
54
+ cursor: pointer;
55
+ border-radius: 6px;
56
+ }
57
+
58
+ .item[data-selected="true"] {
59
+ background: var(--rs-color-background-base-secondary);
60
+ color: var(--rs-color-foreground-accent-primary-hover);
61
+ }
62
+
63
+ .itemContent {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 12px;
67
+ }
68
+
69
+ .resultText {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 8px;
73
+ }
74
+
75
+ .headingText {
76
+ color: var(--rs-color-foreground-base-secondary);
77
+ }
78
+
79
+ .item[data-selected="true"] .headingText {
80
+ color: var(--rs-color-foreground-accent-primary-hover);
81
+ }
82
+
83
+ .separator {
84
+ color: var(--rs-color-foreground-base-secondary);
85
+ }
86
+
87
+ .pageText {
88
+ color: var(--rs-color-foreground-base-primary);
89
+ }
90
+
91
+ .item[data-selected="true"] .pageText {
92
+ color: var(--rs-color-foreground-accent-primary-hover);
93
+ }
94
+
95
+ .icon {
96
+ width: 18px;
97
+ height: 18px;
98
+ color: var(--rs-color-foreground-base-secondary);
99
+ flex-shrink: 0;
100
+ }
101
+
102
+ .item[data-selected="true"] .icon {
103
+ color: var(--rs-color-foreground-accent-primary-hover);
104
+ }
@@ -0,0 +1,202 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Button, Command, Dialog, Text } from "@raystack/apsara";
6
+ import { cx } from "class-variance-authority";
7
+ import { useDocsSearch } from "fumadocs-core/search/client";
8
+ import type { SortedResult } from "fumadocs-core/search";
9
+ import { DocumentIcon, HashtagIcon } from "@heroicons/react/24/outline";
10
+ import { isMacOs } from "react-device-detect";
11
+ import { MethodBadge } from "@/components/api/method-badge";
12
+ import styles from "./search.module.css";
13
+
14
+ function SearchShortcutKey({ className }: { className?: string }) {
15
+ const [key, setKey] = useState("⌘");
16
+
17
+ useEffect(() => {
18
+ setKey(isMacOs ? "⌘" : "Ctrl");
19
+ }, []);
20
+
21
+ return (
22
+ <kbd className={className} suppressHydrationWarning>
23
+ {key} K
24
+ </kbd>
25
+ );
26
+ }
27
+
28
+ interface SearchProps {
29
+ className?: string
30
+ }
31
+
32
+ export function Search({ className }: SearchProps) {
33
+ const [open, setOpen] = useState(false);
34
+ const router = useRouter();
35
+
36
+ const { search, setSearch, query } = useDocsSearch({
37
+ type: "fetch",
38
+ api: "/api/search",
39
+ delayMs: 100,
40
+ allowEmpty: true,
41
+ });
42
+
43
+ const onSelect = useCallback(
44
+ (url: string) => {
45
+ setOpen(false);
46
+ router.push(url);
47
+ },
48
+ [router],
49
+ );
50
+
51
+ useEffect(() => {
52
+ const down = (e: KeyboardEvent) => {
53
+ if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
54
+ e.preventDefault();
55
+ setOpen((open) => !open);
56
+ }
57
+ };
58
+
59
+ document.addEventListener("keydown", down);
60
+ return () => document.removeEventListener("keydown", down);
61
+ }, []);
62
+
63
+ const results = deduplicateByUrl(
64
+ query.data === "empty" ? [] : (query.data ?? []),
65
+ );
66
+
67
+ return (
68
+ <>
69
+ <Button
70
+ variant="outline"
71
+ color="neutral"
72
+ size="small"
73
+ onClick={() => setOpen(true)}
74
+ className={cx(styles.trigger, className)}
75
+ trailingIcon={<SearchShortcutKey className={styles.kbd} />}
76
+ >
77
+ Search...
78
+ </Button>
79
+
80
+ <Dialog open={open} onOpenChange={setOpen}>
81
+ <Dialog.Content className={styles.dialogContent}>
82
+ <Dialog.Title className={styles.visuallyHidden}>
83
+ Search documentation
84
+ </Dialog.Title>
85
+ <Command loop>
86
+ <Command.Input
87
+ placeholder="Search"
88
+ value={search}
89
+ onValueChange={setSearch}
90
+ className={styles.input}
91
+ />
92
+
93
+ <Command.List className={styles.list}>
94
+ {query.isLoading && <Command.Empty>Loading...</Command.Empty>}
95
+ {!query.isLoading &&
96
+ search.length > 0 &&
97
+ results.length === 0 && (
98
+ <Command.Empty>No results found.</Command.Empty>
99
+ )}
100
+ {!query.isLoading &&
101
+ search.length === 0 &&
102
+ results.length > 0 && (
103
+ <Command.Group heading="Suggestions">
104
+ {results.slice(0, 8).map((result: SortedResult) => (
105
+ <Command.Item
106
+ key={result.id}
107
+ value={result.id}
108
+ onSelect={() => onSelect(result.url)}
109
+ className={styles.item}
110
+ >
111
+ <div className={styles.itemContent}>
112
+ {getResultIcon(result)}
113
+ <Text className={styles.pageText}>
114
+ {stripMethod(result.content)}
115
+ </Text>
116
+ </div>
117
+ </Command.Item>
118
+ ))}
119
+ </Command.Group>
120
+ )}
121
+ {search.length > 0 &&
122
+ results.map((result: SortedResult) => (
123
+ <Command.Item
124
+ key={result.id}
125
+ value={result.id}
126
+ onSelect={() => onSelect(result.url)}
127
+ className={styles.item}
128
+ >
129
+ <div className={styles.itemContent}>
130
+ {getResultIcon(result)}
131
+ <div className={styles.resultText}>
132
+ {result.type === "heading" ? (
133
+ <>
134
+ <Text className={styles.headingText}>
135
+ {stripMethod(result.content)}
136
+ </Text>
137
+ <Text className={styles.separator}>-</Text>
138
+ <Text className={styles.pageText}>
139
+ {getPageTitle(result.url)}
140
+ </Text>
141
+ </>
142
+ ) : (
143
+ <Text className={styles.pageText}>
144
+ {stripMethod(result.content)}
145
+ </Text>
146
+ )}
147
+ </div>
148
+ </div>
149
+ </Command.Item>
150
+ ))}
151
+ </Command.List>
152
+ </Command>
153
+ </Dialog.Content>
154
+ </Dialog>
155
+ </>
156
+ );
157
+ }
158
+
159
+ function deduplicateByUrl(results: SortedResult[]): SortedResult[] {
160
+ const seen = new Set<string>();
161
+ return results.filter((r) => {
162
+ const base = r.url.split("#")[0];
163
+ if (seen.has(base)) return false;
164
+ seen.add(base);
165
+ return true;
166
+ });
167
+ }
168
+
169
+ const API_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH"]);
170
+
171
+ function extractMethod(content: string): string | null {
172
+ const first = content.split(" ")[0];
173
+ return API_METHODS.has(first) ? first : null;
174
+ }
175
+
176
+ function stripMethod(content: string): string {
177
+ const first = content.split(" ")[0];
178
+ return API_METHODS.has(first) ? content.slice(first.length + 1) : content;
179
+ }
180
+
181
+ function getResultIcon(result: SortedResult): React.ReactNode {
182
+ if (!result.url.startsWith("/apis/")) {
183
+ return result.type === "page" ? (
184
+ <DocumentIcon className={styles.icon} />
185
+ ) : (
186
+ <HashtagIcon className={styles.icon} />
187
+ );
188
+ }
189
+ const method = extractMethod(result.content);
190
+ return method ? <MethodBadge method={method} size="micro" /> : null;
191
+ }
192
+
193
+ function getPageTitle(url: string): string {
194
+ const path = url.split("#")[0];
195
+ const segments = path.split("/").filter(Boolean);
196
+ const lastSegment = segments[segments.length - 1];
197
+ if (!lastSegment) return "Home";
198
+ return lastSegment
199
+ .split("-")
200
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
201
+ .join(" ");
202
+ }
@@ -0,0 +1,120 @@
1
+ import type { OpenAPIV3 } from 'openapi-types'
2
+ import slugify from 'slugify'
3
+ import type { PageTree, PageTreeItem } from '@/types/content'
4
+ import type { ApiSpec } from './openapi'
5
+
6
+ export function getSpecSlug(spec: ApiSpec): string {
7
+ return slugify(spec.name, { lower: true, strict: true })
8
+ }
9
+
10
+
11
+ export function buildApiRoutes(specs: ApiSpec[]): { slug: string[] }[] {
12
+ const routes: { slug: string[] }[] = []
13
+
14
+ for (const spec of specs) {
15
+ const specSlug = getSpecSlug(spec)
16
+ const paths = spec.document.paths ?? {}
17
+
18
+ for (const [, pathItem] of Object.entries(paths)) {
19
+ if (!pathItem) continue
20
+ for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
21
+ const op = pathItem[method]
22
+ if (!op?.operationId) continue
23
+ routes.push({ slug: [specSlug, encodeURIComponent(op.operationId)] })
24
+ }
25
+ }
26
+ }
27
+
28
+ return routes
29
+ }
30
+
31
+ export interface ApiRouteMatch {
32
+ spec: ApiSpec
33
+ operation: OpenAPIV3.OperationObject
34
+ method: string
35
+ path: string
36
+ }
37
+
38
+ export function findApiOperation(specs: ApiSpec[], slug: string[]): ApiRouteMatch | null {
39
+ if (slug.length !== 2) return null
40
+ const [specSlug, operationId] = slug
41
+
42
+ const spec = specs.find((s) => getSpecSlug(s) === specSlug)
43
+ if (!spec) return null
44
+
45
+ const paths = spec.document.paths ?? {}
46
+ for (const [pathStr, pathItem] of Object.entries(paths)) {
47
+ if (!pathItem) continue
48
+ for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
49
+ const op = pathItem[method]
50
+ if (op?.operationId && encodeURIComponent(op.operationId) === operationId) {
51
+ return { spec, operation: op, method: method.toUpperCase(), path: pathStr }
52
+ }
53
+ }
54
+ }
55
+
56
+ return null
57
+ }
58
+
59
+ export function buildApiPageTree(specs: ApiSpec[]): PageTree {
60
+ const children: PageTreeItem[] = []
61
+
62
+ for (const spec of specs) {
63
+ const specSlug = getSpecSlug(spec)
64
+ const paths = spec.document.paths ?? {}
65
+ const tags = spec.document.tags ?? []
66
+
67
+ // Group operations by tag (case-insensitive to avoid duplicates)
68
+ const opsByTag = new Map<string, PageTreeItem[]>()
69
+ const tagDisplayName = new Map<string, string>()
70
+
71
+ for (const [, pathItem] of Object.entries(paths)) {
72
+ if (!pathItem) continue
73
+ for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
74
+ const op = pathItem[method]
75
+ if (!op?.operationId) continue
76
+
77
+ const rawTag = op.tags?.[0] ?? 'default'
78
+ const tagKey = rawTag.toLowerCase()
79
+ if (!opsByTag.has(tagKey)) {
80
+ opsByTag.set(tagKey, [])
81
+ tagDisplayName.set(tagKey, rawTag.charAt(0).toUpperCase() + rawTag.slice(1))
82
+ }
83
+
84
+ opsByTag.get(tagKey)!.push({
85
+ type: 'page',
86
+ name: op.summary ?? op.operationId,
87
+ url: `/apis/${specSlug}/${encodeURIComponent(op.operationId)}`,
88
+ icon: `method-${method}`,
89
+ })
90
+ }
91
+ }
92
+
93
+ // Use doc.tags display names where available
94
+ for (const t of tags) {
95
+ const key = t.name.toLowerCase()
96
+ if (opsByTag.has(key)) {
97
+ tagDisplayName.set(key, t.name.charAt(0).toUpperCase() + t.name.slice(1))
98
+ }
99
+ }
100
+
101
+ const tagFolders: PageTreeItem[] = Array.from(opsByTag.entries()).map(([key, ops]) => ({
102
+ type: 'folder' as const,
103
+ name: tagDisplayName.get(key) ?? key,
104
+ icon: 'rectangle-stack',
105
+ children: ops,
106
+ }))
107
+
108
+ if (specs.length > 1) {
109
+ children.push({
110
+ type: 'folder',
111
+ name: spec.name,
112
+ children: tagFolders,
113
+ })
114
+ } else {
115
+ children.push(...tagFolders)
116
+ }
117
+ }
118
+
119
+ return { name: 'API Reference', children }
120
+ }
@@ -0,0 +1,47 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { parse } from 'yaml'
4
+ import type { ChronicleConfig } from '@/types'
5
+
6
+ const CONFIG_FILE = 'chronicle.yaml'
7
+
8
+ const defaultConfig: ChronicleConfig = {
9
+ title: 'Documentation',
10
+ theme: { name: 'default' },
11
+ search: { enabled: true, placeholder: 'Search...' },
12
+ }
13
+
14
+ function resolveConfigPath(): string | null {
15
+ const cwdPath = path.join(process.cwd(), CONFIG_FILE)
16
+ if (fs.existsSync(cwdPath)) return cwdPath
17
+ const contentDir = process.env.CHRONICLE_CONTENT_DIR
18
+ if (contentDir) {
19
+ const contentPath = path.join(contentDir, CONFIG_FILE)
20
+ if (fs.existsSync(contentPath)) return contentPath
21
+ }
22
+ return null
23
+ }
24
+
25
+ export function loadConfig(): ChronicleConfig {
26
+ const configPath = resolveConfigPath()
27
+
28
+ if (!configPath) {
29
+ return defaultConfig
30
+ }
31
+
32
+ const raw = fs.readFileSync(configPath, 'utf-8')
33
+ const userConfig = parse(raw) as Partial<ChronicleConfig>
34
+
35
+ return {
36
+ ...defaultConfig,
37
+ ...userConfig,
38
+ theme: {
39
+ name: userConfig.theme?.name ?? defaultConfig.theme!.name,
40
+ colors: { ...defaultConfig.theme?.colors, ...userConfig.theme?.colors },
41
+ },
42
+ search: { ...defaultConfig.search, ...userConfig.search },
43
+ footer: userConfig.footer,
44
+ api: userConfig.api,
45
+ llms: { enabled: false, ...userConfig.llms },
46
+ }
47
+ }
@@ -0,0 +1,10 @@
1
+ import { source } from '@/lib/source'
2
+ import type { InferPageType } from 'fumadocs-core/source'
3
+
4
+ export async function getLLMText(page: InferPageType<typeof source>) {
5
+ const processed = await page.data.getText('processed')
6
+
7
+ return `# ${page.data.title} (${page.url})
8
+
9
+ ${processed}`
10
+ }
@@ -0,0 +1,2 @@
1
+ export * from './config'
2
+ export * from './source'
@@ -0,0 +1,188 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { parse as parseYaml } from 'yaml'
4
+ import type { OpenAPIV2, OpenAPIV3 } from 'openapi-types'
5
+ import type { ApiConfig, ApiServerConfig, ApiAuthConfig } from '@/types/config'
6
+
7
+ type JsonObject = Record<string, unknown>
8
+
9
+ export interface ApiSpec {
10
+ name: string
11
+ basePath: string
12
+ server: ApiServerConfig
13
+ auth?: ApiAuthConfig
14
+ document: OpenAPIV3.Document
15
+ }
16
+
17
+ export type { SchemaField } from './schema'
18
+ export { flattenSchema } from './schema'
19
+
20
+ export function loadApiSpecs(apiConfigs: ApiConfig[]): ApiSpec[] {
21
+ const contentDir = process.env.CHRONICLE_CONTENT_DIR ?? process.cwd()
22
+ return apiConfigs.map((config) => loadApiSpec(config, contentDir))
23
+ }
24
+
25
+ export function loadApiSpec(config: ApiConfig, contentDir: string): ApiSpec {
26
+ const specPath = path.resolve(contentDir, config.spec)
27
+ const raw = fs.readFileSync(specPath, 'utf-8')
28
+ const isYaml = specPath.endsWith('.yaml') || specPath.endsWith('.yml')
29
+ const doc = (isYaml ? parseYaml(raw) : JSON.parse(raw)) as OpenAPIV2.Document | OpenAPIV3.Document
30
+
31
+ let v3Doc: OpenAPIV3.Document
32
+
33
+ if ('swagger' in doc && doc.swagger === '2.0') {
34
+ v3Doc = convertV2toV3(doc as OpenAPIV2.Document)
35
+ } else if ('openapi' in doc && doc.openapi.startsWith('3.')) {
36
+ v3Doc = resolveDocument(doc as OpenAPIV3.Document)
37
+ } else {
38
+ throw new Error(`Unsupported spec version in ${config.spec}`)
39
+ }
40
+
41
+ return {
42
+ name: config.name,
43
+ basePath: config.basePath,
44
+ server: config.server,
45
+ auth: config.auth,
46
+ document: v3Doc,
47
+ }
48
+ }
49
+
50
+ // --- $ref resolution ---
51
+
52
+ function resolveRef(ref: string, root: JsonObject): JsonObject {
53
+ const parts = ref.replace(/^#\//, '').split('/')
54
+ let current: unknown = root
55
+ for (const part of parts) {
56
+ if (current && typeof current === 'object' && !Array.isArray(current)) {
57
+ current = (current as JsonObject)[part]
58
+ } else {
59
+ throw new Error(`Cannot resolve $ref: ${ref}`)
60
+ }
61
+ }
62
+ return current as JsonObject
63
+ }
64
+
65
+ function deepResolveRefs(
66
+ obj: unknown,
67
+ root: JsonObject,
68
+ stack = new Set<string>(),
69
+ cache = new Map<string, JsonObject>(),
70
+ ): unknown {
71
+ if (obj === null || obj === undefined || typeof obj !== 'object') return obj
72
+
73
+ if (Array.isArray(obj)) {
74
+ return obj.map((item) => deepResolveRefs(item, root, stack, cache))
75
+ }
76
+
77
+ const record = obj as JsonObject
78
+
79
+ if (typeof record.$ref === 'string') {
80
+ const ref = record.$ref
81
+ if (cache.has(ref)) return cache.get(ref) as JsonObject
82
+ if (stack.has(ref)) return { type: 'object', description: '[circular]' }
83
+ stack.add(ref)
84
+ const resolved = deepResolveRefs(resolveRef(ref, root), root, stack, cache) as JsonObject
85
+ stack.delete(ref)
86
+ cache.set(ref, resolved)
87
+ return resolved
88
+ }
89
+
90
+ const result: JsonObject = {}
91
+ for (const [key, value] of Object.entries(record)) {
92
+ result[key] = deepResolveRefs(value, root, stack, cache)
93
+ }
94
+ return result
95
+ }
96
+
97
+ function resolveDocument(doc: OpenAPIV3.Document): OpenAPIV3.Document {
98
+ const root = doc as unknown as JsonObject
99
+ return deepResolveRefs(doc, root) as unknown as OpenAPIV3.Document
100
+ }
101
+
102
+ // --- V2 → V3 conversion ---
103
+
104
+ function convertV2toV3(doc: OpenAPIV2.Document): OpenAPIV3.Document {
105
+ const root = doc as unknown as JsonObject
106
+ const resolved = deepResolveRefs(doc, root) as unknown as OpenAPIV2.Document
107
+
108
+ const v3Paths: OpenAPIV3.PathsObject = {}
109
+
110
+ for (const [pathStr, pathItem] of Object.entries(resolved.paths ?? {})) {
111
+ if (!pathItem) continue
112
+ const v3PathItem: OpenAPIV3.PathItemObject = {}
113
+
114
+ for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
115
+ const op = (pathItem as Record<string, unknown>)[method] as OpenAPIV2.OperationObject | undefined
116
+ if (!op) continue
117
+ v3PathItem[method] = convertV2Operation(op)
118
+ }
119
+
120
+ v3Paths[pathStr] = v3PathItem
121
+ }
122
+
123
+ return {
124
+ openapi: '3.0.0',
125
+ info: resolved.info as unknown as OpenAPIV3.InfoObject,
126
+ paths: v3Paths,
127
+ tags: (resolved.tags ?? []) as unknown as OpenAPIV3.TagObject[],
128
+ }
129
+ }
130
+
131
+ function convertV2Operation(op: OpenAPIV2.OperationObject): OpenAPIV3.OperationObject {
132
+ const params = (op.parameters ?? []) as OpenAPIV2.Parameter[]
133
+
134
+ const v3Params: OpenAPIV3.ParameterObject[] = params
135
+ .filter((p) => p.in !== 'body')
136
+ .map((p) => ({
137
+ name: p.name,
138
+ in: p.in as 'path' | 'query' | 'header' | 'cookie',
139
+ required: p.required ?? false,
140
+ description: p.description,
141
+ schema: { type: ((p as JsonObject).type as string) ?? 'string', format: (p as JsonObject).format as string | undefined } as OpenAPIV3.SchemaObject,
142
+ }))
143
+
144
+ const bodyParam = params.find((p) => p.in === 'body') as JsonObject | undefined
145
+ let requestBody: OpenAPIV3.RequestBodyObject | undefined
146
+ if (bodyParam?.schema) {
147
+ requestBody = {
148
+ required: (bodyParam.required as boolean) ?? false,
149
+ content: {
150
+ 'application/json': {
151
+ schema: bodyParam.schema as OpenAPIV3.SchemaObject,
152
+ },
153
+ },
154
+ }
155
+ }
156
+
157
+ const v3Responses: OpenAPIV3.ResponsesObject = {}
158
+ for (const [status, resp] of Object.entries(op.responses ?? {})) {
159
+ const v2Resp = resp as OpenAPIV2.ResponseObject
160
+ const v3Resp: OpenAPIV3.ResponseObject = {
161
+ description: v2Resp.description ?? '',
162
+ }
163
+ if ((v2Resp as unknown as JsonObject).schema) {
164
+ v3Resp.content = {
165
+ 'application/json': {
166
+ schema: (v2Resp as unknown as JsonObject).schema as OpenAPIV3.SchemaObject,
167
+ },
168
+ }
169
+ }
170
+ v3Responses[status] = v3Resp
171
+ }
172
+
173
+ const result: OpenAPIV3.OperationObject = {
174
+ operationId: op.operationId,
175
+ summary: op.summary,
176
+ description: op.description,
177
+ tags: op.tags,
178
+ parameters: v3Params,
179
+ responses: v3Responses,
180
+ }
181
+
182
+ if (requestBody) {
183
+ result.requestBody = requestBody
184
+ }
185
+
186
+ return result
187
+ }
188
+
@@ -0,0 +1,30 @@
1
+ import { visit } from 'unist-util-visit'
2
+ import type { Plugin } from 'unified'
3
+ import type { Node } from 'unist'
4
+
5
+ const remarkUnusedDirectives: Plugin = () => {
6
+ return (tree) => {
7
+ visit(tree, ['textDirective'], (node) => {
8
+ const directive = node as Node & {
9
+ name?: string
10
+ attributes?: Record<string, string>
11
+ children?: Node[]
12
+ value?: string
13
+ [key: string]: unknown
14
+ }
15
+ if (!directive.data) {
16
+ const hasAttributes = directive.attributes && Object.keys(directive.attributes).length > 0
17
+ const hasChildren = directive.children && directive.children.length > 0
18
+ if (!hasAttributes && !hasChildren) {
19
+ const name = directive.name
20
+ if (!name) return
21
+ Object.keys(directive).forEach((key) => delete directive[key])
22
+ directive.type = 'text'
23
+ directive.value = `:${name}`
24
+ }
25
+ }
26
+ })
27
+ }
28
+ }
29
+
30
+ export default remarkUnusedDirectives