@apimatic/cli 2.0.0-beta.2 → 2.0.0-beta.3
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/README.md +19 -6
- package/lib/actions/plugin/generate.d.ts +0 -2
- package/lib/actions/plugin/generate.js +5 -8
- package/lib/actions/plugin/generate.js.map +1 -1
- package/lib/actions/portal/serve.js +4 -3
- package/lib/actions/portal/serve.js.map +1 -1
- package/lib/actions/sdk/generate.js +16 -3
- package/lib/actions/sdk/generate.js.map +1 -1
- package/lib/actions/sdk/publish.js +6 -2
- package/lib/actions/sdk/publish.js.map +1 -1
- package/lib/infrastructure/services/portal-artifacts-service.js +6 -5
- package/lib/infrastructure/services/portal-artifacts-service.js.map +1 -1
- package/lib/infrastructure/zip-service.d.ts +6 -2
- package/lib/infrastructure/zip-service.js +42 -24
- package/lib/infrastructure/zip-service.js.map +1 -1
- package/lib/prompts/plugin/generate.d.ts +3 -3
- package/lib/prompts/plugin/generate.js +19 -11
- package/lib/prompts/plugin/generate.js.map +1 -1
- package/lib/prompts/portal/serve.js +1 -1
- package/lib/prompts/portal/serve.js.map +1 -1
- package/lib/prompts/portal/source.d.ts +4 -2
- package/lib/prompts/portal/source.js +42 -8
- package/lib/prompts/portal/source.js.map +1 -1
- package/lib/prompts/sdk/generate.d.ts +5 -0
- package/lib/prompts/sdk/generate.js +10 -0
- package/lib/prompts/sdk/generate.js.map +1 -1
- package/lib/prompts/sdk/publish.d.ts +3 -0
- package/lib/prompts/sdk/publish.js +3 -0
- package/lib/prompts/sdk/publish.js.map +1 -1
- package/lib/types/file/file-problem.d.ts +4 -0
- package/lib/types/file/file-problem.js +2 -0
- package/lib/types/file/file-problem.js.map +1 -0
- package/lib/types/plugin/generation-problem.d.ts +10 -0
- package/lib/types/plugin/generation-problem.js +2 -0
- package/lib/types/plugin/generation-problem.js.map +1 -0
- package/lib/types/plugin-config-context.js +2 -1
- package/lib/types/plugin-config-context.js.map +1 -1
- package/lib/types/plugin-context.d.ts +3 -1
- package/lib/types/plugin-context.js +2 -1
- package/lib/types/plugin-context.js.map +1 -1
- package/lib/types/portal/content-notices.d.ts +11 -3
- package/lib/types/portal/content-tree.d.ts +4 -2
- package/lib/types/portal/content-tree.js +42 -12
- package/lib/types/portal/content-tree.js.map +1 -1
- package/lib/types/portal/generated-pages.d.ts +6 -6
- package/lib/types/portal/generated-pages.js +3 -3
- package/lib/types/portal/generated-pages.js.map +1 -1
- package/lib/types/portal/portal-navigation.d.ts +83 -12
- package/lib/types/portal/portal-navigation.js +276 -100
- package/lib/types/portal/portal-navigation.js.map +1 -1
- package/lib/types/portal/preview-content.js +15 -2
- package/lib/types/portal/preview-content.js.map +1 -1
- package/lib/types/portal-context.d.ts +1 -0
- package/lib/types/portal-context.js +17 -12
- package/lib/types/portal-context.js.map +1 -1
- package/lib/types/portal-source-context.js +7 -4
- package/lib/types/portal-source-context.js.map +1 -1
- package/lib/types/project-context.d.ts +2 -1
- package/lib/types/project-context.js +2 -2
- package/lib/types/project-context.js.map +1 -1
- package/lib/types/sdk-context.d.ts +7 -2
- package/lib/types/sdk-context.js +8 -6
- package/lib/types/sdk-context.js.map +1 -1
- package/lib/types/spec-context.d.ts +3 -1
- package/lib/types/spec-context.js +5 -4
- package/lib/types/spec-context.js.map +1 -1
- package/lib/types/temp-context.d.ts +5 -3
- package/lib/types/temp-context.js +7 -7
- package/lib/types/temp-context.js.map +1 -1
- package/oclif.manifest.json +164 -164
- package/package.json +7 -6
- package/portal-template/src/components/api-page.tsx +2 -6
- package/portal-template/src/components/example-layout.tsx +7 -7
- package/portal-template/src/components/usage-tabs.tsx +16 -36
- package/portal-template/src/lib/media-adapters.ts +3 -3
- package/portal-template/src/lib/navigation-schema.ts +13 -0
- package/portal-template/src/lib/navigation.ts +60 -117
- package/portal-template/src/lib/request-examples.ts +12 -28
- package/portal-template/src/lib/source.ts +3 -2
- package/portal-template/src/lib/tabs.ts +3 -2
- package/portal-template/src/components/code-block.tsx +0 -9
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
2
|
import { CodeBlockTab, CodeBlockTabs, CodeBlockTabsList, CodeBlockTabsTrigger } from 'fumadocs-ui/components/codeblock';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { useComponents } from 'fumadocs-openapi';
|
|
4
|
+
import { useCodeUsage } from 'fumadocs-openapi/operation';
|
|
5
|
+
import { createCodeUsageGeneratorRegistry } from 'fumadocs-openapi/requests/generators';
|
|
5
6
|
import { curl } from 'fumadocs-openapi/requests/generators/curl';
|
|
6
|
-
import { joinURL, resolveServerUrl } from '@fumadocs/api-docs/utils/url';
|
|
7
7
|
import { CodeSample } from '@/lib/code-samples';
|
|
8
|
-
import { CodeBlock } from './code-block';
|
|
9
8
|
import { useExampleSelection } from './example-layout';
|
|
10
9
|
|
|
11
10
|
interface UsageTab {
|
|
@@ -14,6 +13,13 @@ interface UsageTab {
|
|
|
14
13
|
body: ReactNode;
|
|
15
14
|
}
|
|
16
15
|
|
|
16
|
+
// Not `curl`: Fumadocs files an operation's `x-codeSamples` under their language, which would replace it.
|
|
17
|
+
const CURL_USAGE_ID = 'portal-curl';
|
|
18
|
+
|
|
19
|
+
// Without a registry of its own, Fumadocs registers and bundles every request generator it has.
|
|
20
|
+
export const codeUsages = createCodeUsageGeneratorRegistry();
|
|
21
|
+
codeUsages.add(CURL_USAGE_ID, curl);
|
|
22
|
+
|
|
17
23
|
// The registry Fumadocs passes in adds the operation's `x-codeSamples`, which cannot follow the example selector.
|
|
18
24
|
export function renderUsageTabs(): ReactNode {
|
|
19
25
|
return <UsageTabs />;
|
|
@@ -48,6 +54,7 @@ function UsageTabs() {
|
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
function SampleCode({ sample }: Readonly<{ sample: CodeSample }>) {
|
|
57
|
+
const { CodeBlock } = useComponents();
|
|
51
58
|
const { examples, selected } = useExampleSelection();
|
|
52
59
|
const source = sample.sourceFor(selected.id, examples.length);
|
|
53
60
|
|
|
@@ -57,36 +64,9 @@ function SampleCode({ sample }: Readonly<{ sample: CodeSample }>) {
|
|
|
57
64
|
return <CodeBlock lang={sample.lang} code={source} />;
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
// Follows the playground's edits to the selected example, on the selected server.
|
|
60
68
|
function CurlCode() {
|
|
61
|
-
const {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
const serverUrl = useServerUrl();
|
|
65
|
-
|
|
66
|
-
if (request === undefined) return null;
|
|
67
|
-
const url = joinURL(serverUrl, pathnameFromRequest(route, request));
|
|
68
|
-
return <CodeBlock lang={curl.lang} code={curl.generate({ ...request, url }, { mediaAdapters, custom: null })} />;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Fumadocs reports the playground's edits to the selected example through its listeners.
|
|
72
|
-
function useSelectedRequest() {
|
|
73
|
-
const { examples, example, addListener, removeListener } = useOperationContext();
|
|
74
|
-
const [request, setRequest] = useState(() => examples.find((item) => item.id === example)?.encoded);
|
|
75
|
-
|
|
76
|
-
useEffect(() => {
|
|
77
|
-
const listener = (_: unknown, encoded: typeof request) => setRequest(encoded);
|
|
78
|
-
addListener(listener);
|
|
79
|
-
return () => removeListener(listener);
|
|
80
|
-
}, [addListener, removeListener]);
|
|
81
|
-
return request;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function useServerUrl(): string {
|
|
85
|
-
const { server } = useServerContext();
|
|
86
|
-
const [origin, setOrigin] = useState<string>();
|
|
87
|
-
|
|
88
|
-
useEffect(() => setOrigin(window.location.origin), []);
|
|
89
|
-
return server && origin
|
|
90
|
-
? new URL(resolveServerUrl(server.url, server.variables), origin).href
|
|
91
|
-
: 'https://example.com';
|
|
69
|
+
const { CodeBlock } = useComponents();
|
|
70
|
+
const code = useCodeUsage(CURL_USAGE_ID);
|
|
71
|
+
return code === undefined ? null : <CodeBlock lang={curl.lang} code={code} />;
|
|
92
72
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { MediaAdapter } from 'fumadocs-openapi';
|
|
2
2
|
|
|
3
|
-
// Fumadocs
|
|
3
|
+
// Fumadocs will not send a request body type it has no adapter for, and matches a wildcard type only as written.
|
|
4
4
|
const UNADAPTED_MEDIA_TYPES = [
|
|
5
5
|
'*/*',
|
|
6
6
|
'application/*',
|
|
@@ -9,7 +9,7 @@ const UNADAPTED_MEDIA_TYPES = [
|
|
|
9
9
|
'image/*',
|
|
10
10
|
'audio/*',
|
|
11
11
|
'video/*',
|
|
12
|
-
// Fumadocs' own fallback
|
|
12
|
+
// Fumadocs' own fallback sends text/plain through its JSON encoder, which quotes the text.
|
|
13
13
|
'text/plain',
|
|
14
14
|
'text/json',
|
|
15
15
|
'text/html',
|
|
@@ -27,7 +27,7 @@ const UNADAPTED_MEDIA_TYPES = [
|
|
|
27
27
|
const asEntered: MediaAdapter = {
|
|
28
28
|
// An object the playground built from the schema would otherwise be sent as "[object Object]".
|
|
29
29
|
encode: ({ body }) => (typeof body === 'string' || body instanceof Blob ? body : JSON.stringify(body)),
|
|
30
|
-
//
|
|
30
|
+
// cURL, the one generator the portal registers, never asks for a body example.
|
|
31
31
|
generateExample: () => undefined
|
|
32
32
|
};
|
|
33
33
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { metaSchema } from 'fumadocs-core/source/schema';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fumadocs' metadata plus the root file's `tabs`, typed as `pages` is. Fumadocs strips what the
|
|
5
|
+
* collection's schema does not name, so without this the key would never reach the transformer.
|
|
6
|
+
* Its own module: `defineDocs` from `fumadocs-mdx/macro` throws outside the bundler, so no test
|
|
7
|
+
* can import `source.ts`.
|
|
8
|
+
*/
|
|
9
|
+
export const navigationSchema: NavigationSchema = metaSchema.extend({ tabs: metaSchema.shape.pages });
|
|
10
|
+
|
|
11
|
+
// Spelled through `metaSchema`: an inferred type would name Zod's classes, which this project
|
|
12
|
+
// does not depend on directly, and the test project checks declarations.
|
|
13
|
+
type NavigationSchema = ReturnType<typeof metaSchema.extend<{ tabs: typeof metaSchema.shape.pages }>>;
|
|
@@ -26,11 +26,7 @@ const REST_TOKEN = '...';
|
|
|
26
26
|
/** The API reference, positioned as one node. */
|
|
27
27
|
const API_REFERENCE_TOKEN = 'apimatic:api';
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
* The folders the CLI generates, each a tab of its own, by the token that positions it and in
|
|
31
|
-
* the order they take when the root `nav.json` names none of them. The CLI's
|
|
32
|
-
* `GENERATED_SECTIONS` lists the same, and a test holds the two together.
|
|
33
|
-
*/
|
|
29
|
+
/** The folders the CLI generates, by the token that positions each; the CLI's `GENERATED_SECTIONS` lists the same. */
|
|
34
30
|
export const GENERATED_SECTIONS = [
|
|
35
31
|
{ token: 'apimatic:sdks', folder: 'sdks' },
|
|
36
32
|
{ token: 'apimatic:plugin', folder: 'context-plugin' }
|
|
@@ -43,15 +39,15 @@ const API_REFERENCE_TITLE = 'API Reference';
|
|
|
43
39
|
/** Where the home page is served, which is how its node is told apart from every other page. */
|
|
44
40
|
const HOME_URL = docsRoute;
|
|
45
41
|
|
|
46
|
-
/** Fixed, as tab matching goes by id after serialisation; Fumadocs' ids never start with a slash. */
|
|
47
|
-
const HOME_TAB_ID = '/tab/home';
|
|
48
|
-
|
|
49
42
|
/** The Home tab's name when the root `nav.json` gives none, and the fallback home page's. */
|
|
50
43
|
const HOME_NAME = 'Home';
|
|
51
44
|
|
|
52
45
|
/** The node the fallback home page gets when there is no index page; see `withFallbackHomePage`. */
|
|
53
46
|
const SYNTHETIC_HOME_ID = '/page/home';
|
|
54
47
|
|
|
48
|
+
/** The Home tab's id. Fixed, as tab matching goes by id after serialisation; Fumadocs' ids never start with a slash. */
|
|
49
|
+
const HOME_TAB_ID = '/tab/home';
|
|
50
|
+
|
|
55
51
|
/**
|
|
56
52
|
* The key the generated pages are passed to `loader()` under, which the storage stamps onto
|
|
57
53
|
* every file of that source.
|
|
@@ -82,16 +78,10 @@ export function navigationTransformer<S extends ContentStorage>(): PageTreeTrans
|
|
|
82
78
|
// folder whose only page is its index is not empty: the folder itself links to it.
|
|
83
79
|
node.children = node.children.filter((child) => !isEmptyFolder(child));
|
|
84
80
|
|
|
85
|
-
//
|
|
86
|
-
// generated sections and the API reference at their defaults, and Fumadocs' own order
|
|
87
|
-
// would not. It sorts folders by path, so `api` lands above a user folder called anything
|
|
88
|
-
// later in the alphabet, and a generated folder lands in the middle of the user's pages.
|
|
89
|
-
// Below the root those defaults do not apply, so a folder with no file keeps Fumadocs'
|
|
90
|
-
// order untouched, which is every tag folder of every specification.
|
|
81
|
+
// A directory with no order keeps Fumadocs' own, the root included.
|
|
91
82
|
const settings = readSettings(this, folderPath);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
node.children = reorder(this, node, folderPath, order ?? []);
|
|
83
|
+
if (settings?.pages !== undefined) {
|
|
84
|
+
node.children = reorder(this, node, folderPath, settings.pages);
|
|
95
85
|
}
|
|
96
86
|
if (folderPath === apiBaseDir) {
|
|
97
87
|
applyApiStructure(this, node);
|
|
@@ -105,7 +95,7 @@ export function navigationTransformer<S extends ContentStorage>(): PageTreeTrans
|
|
|
105
95
|
};
|
|
106
96
|
}
|
|
107
97
|
|
|
108
|
-
/** A tab per
|
|
98
|
+
/** A tab per entry of the root `nav.json`'s `tabs`, in its order; everything else is Home. */
|
|
109
99
|
export function tabsTransformer<S extends ContentStorage>(): PageTreeTransformer<S> {
|
|
110
100
|
return {
|
|
111
101
|
root(root) {
|
|
@@ -119,6 +109,8 @@ export function tabsTransformer<S extends ContentStorage>(): PageTreeTransformer
|
|
|
119
109
|
interface NavigationSettings {
|
|
120
110
|
pages: string[] | undefined;
|
|
121
111
|
title: string | undefined;
|
|
112
|
+
/** Read at the root only; the CLI refuses it anywhere else. */
|
|
113
|
+
tabs: string[] | undefined;
|
|
122
114
|
}
|
|
123
115
|
|
|
124
116
|
function readSettings(context: NavigationContext, folderPath: string): NavigationSettings | undefined {
|
|
@@ -130,41 +122,36 @@ function readSettings(context: NavigationContext, folderPath: string): Navigatio
|
|
|
130
122
|
return undefined;
|
|
131
123
|
}
|
|
132
124
|
|
|
133
|
-
const { pages, title } = file.data as { pages?: unknown; title?: unknown };
|
|
125
|
+
const { pages, tabs, title } = file.data as { pages?: unknown; tabs?: unknown; title?: unknown };
|
|
134
126
|
// A half-typed title reloads to here as an empty string, which would blank the folder in
|
|
135
127
|
// the sidebar with nothing to click. The CLI refuses one; the preview keeps the default
|
|
136
128
|
// name until the file is worth reading again.
|
|
137
129
|
const named = typeof title === 'string' ? title.trim() : '';
|
|
138
130
|
return {
|
|
139
|
-
pages:
|
|
131
|
+
pages: stringsOf(pages),
|
|
132
|
+
tabs: stringsOf(tabs),
|
|
140
133
|
title: named.length > 0 ? named : undefined
|
|
141
134
|
};
|
|
142
135
|
}
|
|
143
136
|
|
|
144
|
-
function
|
|
145
|
-
|
|
146
|
-
return (
|
|
147
|
-
folderPath !== undefined &&
|
|
148
|
-
folderPath !== apiBaseDir &&
|
|
149
|
-
lists(rootSettings, folderPath) &&
|
|
150
|
-
// The home page is the Home tab's, whichever `(group)` folder serves it.
|
|
151
|
-
!containsUrl([folder], HOME_URL)
|
|
152
|
-
);
|
|
137
|
+
function stringsOf(entries: unknown): string[] | undefined {
|
|
138
|
+
return Array.isArray(entries) ? entries.filter((entry): entry is string => typeof entry === 'string') : undefined;
|
|
153
139
|
}
|
|
154
140
|
|
|
141
|
+
/** The nodes the root `tabs` names become the tabs, in its order; everything else is Home. */
|
|
155
142
|
function groupIntoTabs(context: NavigationContext, children: Node[]): Node[] {
|
|
156
143
|
const settings = readSettings(context, '');
|
|
144
|
+
const remaining = new Set(children);
|
|
157
145
|
const tabs: Folder[] = [];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
loose.push(child);
|
|
146
|
+
|
|
147
|
+
// Resolved as `reorder` resolves an entry, before the tabs lose their `$ref`. A page is no
|
|
148
|
+
// tab, and the home page is the Home tab's whichever `(group)` folder serves it; the CLI
|
|
149
|
+
// refuses both entries, and an entry naming nothing is left to it as well.
|
|
150
|
+
for (const raw of settings?.tabs ?? []) {
|
|
151
|
+
const node = nodeNamed(context, [...remaining], '', raw.trim());
|
|
152
|
+
if (node?.type === 'folder' && !containsUrl([node], HOME_URL)) {
|
|
153
|
+
remaining.delete(node);
|
|
154
|
+
tabs.push(asTab(node));
|
|
168
155
|
}
|
|
169
156
|
}
|
|
170
157
|
|
|
@@ -181,7 +168,7 @@ function groupIntoTabs(context: NavigationContext, children: Node[]): Node[] {
|
|
|
181
168
|
$id: HOME_TAB_ID,
|
|
182
169
|
name: settings?.title ?? HOME_NAME,
|
|
183
170
|
root: true,
|
|
184
|
-
children: withFallbackHomePage(
|
|
171
|
+
children: withFallbackHomePage([...remaining])
|
|
185
172
|
};
|
|
186
173
|
// Home opens the site, so it leads wherever the file lists its pages; `index` orders them only.
|
|
187
174
|
return [home, ...tabs];
|
|
@@ -209,10 +196,6 @@ function asTab(folder: Folder): Folder {
|
|
|
209
196
|
return folder;
|
|
210
197
|
}
|
|
211
198
|
|
|
212
|
-
function lists(settings: NavigationSettings | undefined, name: string): boolean {
|
|
213
|
-
return settings?.pages?.some((entry) => entry.trim() === name) ?? false;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
199
|
/**
|
|
217
200
|
* Titles the wrapper and, for a single specification, lifts its section away.
|
|
218
201
|
*
|
|
@@ -268,66 +251,51 @@ function firstPageIn(folder: Folder): Node | undefined {
|
|
|
268
251
|
return undefined;
|
|
269
252
|
}
|
|
270
253
|
|
|
254
|
+
/**
|
|
255
|
+
* `node`'s children with the ones `order` names first, in its order, and the rest where its
|
|
256
|
+
* rest token stands, or after everything named when it has none. The rest keep Fumadocs' own
|
|
257
|
+
* order: pages before folders, each by path.
|
|
258
|
+
*/
|
|
271
259
|
function reorder(context: NavigationContext, node: Folder, folderPath: string, order: string[]): Node[] {
|
|
272
260
|
const remaining = new Set(node.children);
|
|
273
|
-
// Asked once per child: the token, both bands and the anchor all want to know, and each
|
|
274
|
-
// answer is a storage read.
|
|
275
|
-
const injectedChildren = new Set(node.children.filter((child) => isInjected(context, child)));
|
|
276
|
-
const isInjectedChild = (child: Node): boolean => injectedChildren.has(child);
|
|
277
261
|
const named: Node[] = [];
|
|
278
262
|
let restIndex: number | undefined;
|
|
279
263
|
|
|
280
|
-
const claim = (child: Node | undefined): void => {
|
|
281
|
-
if (child !== undefined) {
|
|
282
|
-
remaining.delete(child);
|
|
283
|
-
named.push(child);
|
|
284
|
-
}
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
// Every token names a node that lives at the content root, and the CLI refuses each one
|
|
288
|
-
// anywhere else. Honouring them in a nested directory would let the two halves of the
|
|
289
|
-
// format disagree about a file only one of them had rejected.
|
|
290
|
-
const isContentRoot = folderPath === '';
|
|
291
|
-
|
|
292
264
|
for (const raw of order) {
|
|
293
265
|
const entry = raw.trim();
|
|
294
|
-
const section = GENERATED_SECTIONS.find((candidate) => candidate.token === entry);
|
|
295
266
|
if (entry === REST_TOKEN) {
|
|
296
267
|
restIndex = named.length;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
claim([...remaining].find((child) => isInjectedChild(child) && isSectionFolder(child, section)));
|
|
304
|
-
}
|
|
305
|
-
} else {
|
|
306
|
-
claim(matching(context, [...remaining], folderPath, entry));
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const child = nodeNamed(context, [...remaining], folderPath, entry);
|
|
271
|
+
if (child !== undefined) {
|
|
272
|
+
remaining.delete(child);
|
|
273
|
+
named.push(child);
|
|
307
274
|
}
|
|
308
275
|
}
|
|
309
276
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
277
|
+
const at = restIndex ?? named.length;
|
|
278
|
+
return [...named.slice(0, at), ...remaining, ...named.slice(at)];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The child an entry names, or undefined. A token names a node that lives at the content root,
|
|
283
|
+
* and the CLI refuses each one anywhere else; honouring it in a nested directory would let the
|
|
284
|
+
* two halves of the format disagree about a file only one of them had rejected.
|
|
285
|
+
*/
|
|
286
|
+
function nodeNamed(context: NavigationContext, children: Node[], folderPath: string, entry: string): Node | undefined {
|
|
287
|
+
const isContentRoot = folderPath === '';
|
|
288
|
+
const section = GENERATED_SECTIONS.find((candidate) => candidate.token === entry);
|
|
289
|
+
if (entry === API_REFERENCE_TOKEN) {
|
|
290
|
+
return isContentRoot ? children.find((child) => isApiReference(child)) : undefined;
|
|
291
|
+
}
|
|
292
|
+
if (section !== undefined) {
|
|
293
|
+
// The cheap path check first: `isInjected` reads storage.
|
|
294
|
+
return isContentRoot
|
|
295
|
+
? children.find((child) => isSectionFolder(child, section) && isInjected(context, child))
|
|
296
|
+
: undefined;
|
|
297
|
+
}
|
|
298
|
+
return matching(context, children, folderPath, entry);
|
|
331
299
|
}
|
|
332
300
|
|
|
333
301
|
/**
|
|
@@ -346,22 +314,6 @@ function matching(context: NavigationContext, children: Node[], folderPath: stri
|
|
|
346
314
|
return children.find((child) => child.type === 'page' && child.$ref === pagePath);
|
|
347
315
|
}
|
|
348
316
|
|
|
349
|
-
function afterNamedContent(named: Node[], isInjectedChild: (child: Node) => boolean): number {
|
|
350
|
-
for (let index = named.length - 1; index >= 0; index -= 1) {
|
|
351
|
-
if (!isApiReference(named[index]) && !isInjectedChild(named[index])) {
|
|
352
|
-
return index + 1;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
// Nothing named is the user's own, so everything named is what the CLI adds, and the
|
|
356
|
-
// user's pages keep their default place above all of it.
|
|
357
|
-
return 0;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function anchorIn(children: Node[]): number {
|
|
361
|
-
const index = children.findIndex((child) => isApiReference(child));
|
|
362
|
-
return index === -1 ? children.length : index;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
317
|
function isApiReference(child: Node): child is Folder {
|
|
366
318
|
return child.type === 'folder' && child.$ref?.folder === apiBaseDir;
|
|
367
319
|
}
|
|
@@ -383,15 +335,6 @@ function isInjected(context: NavigationContext, child: Node): boolean {
|
|
|
383
335
|
return isFromSource(context, child, GENERATED_SOURCE);
|
|
384
336
|
}
|
|
385
337
|
|
|
386
|
-
/**
|
|
387
|
-
* Where a generated folder comes in `GENERATED_SECTIONS`. Anything else sorts after them, which
|
|
388
|
-
* only a loose generated page could be, and the CLI writes none.
|
|
389
|
-
*/
|
|
390
|
-
function sectionRank(child: Node): number {
|
|
391
|
-
const rank = GENERATED_SECTIONS.findIndex((section) => isSectionFolder(child, section));
|
|
392
|
-
return rank === -1 ? GENERATED_SECTIONS.length : rank;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
338
|
function isSectionFolder(child: Node, section: (typeof GENERATED_SECTIONS)[number]): boolean {
|
|
396
339
|
return child.type === 'folder' && child.$ref?.folder === section.folder;
|
|
397
340
|
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ParameterObject } from 'fumadocs-openapi';
|
|
2
|
+
import type { ExampleRequest, OperationParameters } from 'fumadocs-openapi/operation';
|
|
2
3
|
import { isJsonObject } from './json';
|
|
3
4
|
|
|
4
|
-
export type RequestExample = Pick<
|
|
5
|
-
|
|
6
|
-
'id' | 'name' | 'description'
|
|
7
|
-
>;
|
|
8
|
-
type Location = 'path' | 'query' | 'header' | 'cookie';
|
|
5
|
+
export type RequestExample = Pick<ExampleRequest, 'id' | 'name' | 'description'>;
|
|
6
|
+
type Location = OperationParameters['in'];
|
|
9
7
|
type Resolve = (node: unknown) => unknown;
|
|
10
8
|
|
|
11
9
|
interface Example {
|
|
@@ -25,22 +23,16 @@ export class Parameter {
|
|
|
25
23
|
private readonly examples: Map<string, Example>
|
|
26
24
|
) {}
|
|
27
25
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
);
|
|
26
|
+
// Takes the parameters Fumadocs has already resolved, deduplicated and grouped by location.
|
|
27
|
+
public static listIn(groups: OperationParameters[], resolve: Resolve): Parameter[] {
|
|
28
|
+
return groups.flatMap((group) => group.items.map((item) => Parameter.from(group.in, item, resolve)));
|
|
32
29
|
}
|
|
33
30
|
|
|
34
|
-
private static from(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
? Object.entries(entry.examples)
|
|
40
|
-
.map(([id, example]): [string, unknown] => [id, resolve(example)])
|
|
41
|
-
.filter(isExampleEntry)
|
|
42
|
-
: [];
|
|
43
|
-
return new Parameter(entry.in, new Map(examples));
|
|
31
|
+
private static from(location: Location, item: ParameterObject, resolve: Resolve): Parameter {
|
|
32
|
+
const examples = Object.entries(item.examples ?? {})
|
|
33
|
+
.map(([id, example]): [string, unknown] => [id, resolve(example)])
|
|
34
|
+
.filter(isExampleEntry);
|
|
35
|
+
return new Parameter(location, new Map(examples));
|
|
44
36
|
}
|
|
45
37
|
|
|
46
38
|
public namedExamples(): [string, Example][] {
|
|
@@ -58,14 +50,6 @@ export function requestExamples(bodyExamples: RequestExample[], parameters: Para
|
|
|
58
50
|
return named.map(([id, { summary, description }]) => ({ id, name: summary || id, description }));
|
|
59
51
|
}
|
|
60
52
|
|
|
61
|
-
function parametersOf(node: unknown): unknown[] {
|
|
62
|
-
return isJsonObject(node) && Array.isArray(node.parameters) ? node.parameters : [];
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function isLocation(value: unknown): value is Location {
|
|
66
|
-
return value === 'path' || value === 'query' || value === 'header' || value === 'cookie';
|
|
67
|
-
}
|
|
68
|
-
|
|
69
53
|
function isExampleEntry(entry: [string, unknown]): entry is [string, Example] {
|
|
70
54
|
return isJsonObject(entry[1]) && 'value' in entry[1];
|
|
71
55
|
}
|
|
@@ -3,6 +3,7 @@ import { applyMdxPreset } from 'fumadocs-mdx/config';
|
|
|
3
3
|
import { rehypeCodeDefaultOptions } from 'fumadocs-core/mdx-plugins';
|
|
4
4
|
import rehypeRaw from 'rehype-raw';
|
|
5
5
|
import { rehypeCodeTitles, rehypeKeepCodeMeta } from './rehype-code-titles';
|
|
6
|
+
import { navigationSchema } from './navigation-schema';
|
|
6
7
|
|
|
7
8
|
// What `rehype-raw` must hand on untouched, being MDX's own nodes rather than HTML.
|
|
8
9
|
const MDX_NODE_TYPES = ['mdxFlowExpression', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxTextExpression', 'mdxjsEsm'];
|
|
@@ -18,8 +19,8 @@ export const docs = defineDocs({
|
|
|
18
19
|
// Restricted to our own file, replacing the default of every .json and .yaml in the
|
|
19
20
|
// content directory. Without it a leftover `meta.json` is loaded as a folder's metadata
|
|
20
21
|
// and applied before the transformer runs -- and since a metadata file hides whatever it
|
|
21
|
-
// does not name, the transformer could not put those pages back.
|
|
22
|
-
meta: { files: ['**/nav.json'] }
|
|
22
|
+
// does not name, the transformer could not put those pages back. The schema keeps `tabs`.
|
|
23
|
+
meta: { files: ['**/nav.json'], schema: navigationSchema }
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
// The pages the CLI writes into this project, resolved against the Vite root like `content`.
|
|
@@ -7,10 +7,10 @@ import { containsUrl, docsRoute } from './shared';
|
|
|
7
7
|
* transformer has run. Given to the layout rather than left to Fumadocs, which links a tab to
|
|
8
8
|
* its folder's first direct page and so leaves out a tab holding only folders -- as the API
|
|
9
9
|
* reference does whenever its operations are grouped. Each stays bound to its folder, which is
|
|
10
|
-
* how the active tab is found.
|
|
10
|
+
* how the active tab is found. A switcher with one choice switches nothing, so one tab is none.
|
|
11
11
|
*/
|
|
12
12
|
export function portalTabs(tree: PageTree.Root): LayoutTab[] {
|
|
13
|
-
|
|
13
|
+
const tabs = tree.children.flatMap((node) => {
|
|
14
14
|
if (node.type !== 'folder' || node.root !== true) return [];
|
|
15
15
|
// Home keeps the order the root nav.json gives, which need not put the home page first.
|
|
16
16
|
const url = containsUrl([node], docsRoute) ? docsRoute : firstPageUrl(node);
|
|
@@ -18,6 +18,7 @@ export function portalTabs(tree: PageTree.Root): LayoutTab[] {
|
|
|
18
18
|
? []
|
|
19
19
|
: [{ title: node.name, description: node.description, icon: node.icon, url, $folder: node }];
|
|
20
20
|
});
|
|
21
|
+
return tabs.length > 1 ? tabs : [];
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock.core';
|
|
2
|
-
import { useRenderContext } from 'fumadocs-openapi/ui';
|
|
3
|
-
|
|
4
|
-
// Fumadocs passes `JSON.stringify(example.value)`, which is undefined for an example with no `value`.
|
|
5
|
-
export function CodeBlock({ lang, code }: Readonly<{ lang: string; code: string | undefined }>) {
|
|
6
|
-
const { shiki, shikiOptions } = useRenderContext();
|
|
7
|
-
if (code === undefined) return null;
|
|
8
|
-
return <DynamicCodeBlock lang={lang} code={code} highlighter={() => shiki.getOrInit()} options={shikiOptions} />;
|
|
9
|
-
}
|