@lukoweb/apitogo 0.2.15 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/cli.js +729 -601
- package/package.json +1 -1
- package/src/config/v2/docs-navigation.ts +72 -0
- package/src/config/v2/home-redirect.ts +98 -0
- package/src/config/v2/wrapper.ts +40 -8
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { InputNavigationItem } from "../validators/InputNavigationSchema.js";
|
|
2
|
+
|
|
3
|
+
/** Label of the one header tab that holds the sidebar made from the docs pages. */
|
|
4
|
+
export const DOCS_NAVIGATION_LABEL = "Documentation";
|
|
5
|
+
|
|
6
|
+
type Branch = { pages: Set<string>; folders: Map<string, Branch> };
|
|
7
|
+
|
|
8
|
+
const emptyBranch = (): Branch => ({ pages: new Set(), folders: new Map() });
|
|
9
|
+
|
|
10
|
+
/** "getting-started" becomes "Getting started". */
|
|
11
|
+
export function folderLabel(name: string): string {
|
|
12
|
+
const words = name.replace(/[-_]+/g, " ").trim();
|
|
13
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const byName = (a: string, b: string) => a.localeCompare(b);
|
|
17
|
+
|
|
18
|
+
const toItems = (branch: Branch, prefix: string): InputNavigationItem[] => {
|
|
19
|
+
const pages = [...branch.pages].sort((a, b) =>
|
|
20
|
+
a === "index" ? -1 : b === "index" ? 1 : byName(a, b),
|
|
21
|
+
);
|
|
22
|
+
const folders = [...branch.folders.keys()].sort(byName);
|
|
23
|
+
return [
|
|
24
|
+
// The index page of a folder is the folder's own address, so the sidebar also shows on `/`.
|
|
25
|
+
...pages.map(
|
|
26
|
+
(page): InputNavigationItem =>
|
|
27
|
+
page === "index"
|
|
28
|
+
? {
|
|
29
|
+
type: "doc",
|
|
30
|
+
file: `${prefix}${page}`,
|
|
31
|
+
path: prefix === "" ? "/" : `/${prefix.slice(0, -1)}`,
|
|
32
|
+
}
|
|
33
|
+
: `${prefix}${page}`,
|
|
34
|
+
),
|
|
35
|
+
...folders.map(
|
|
36
|
+
(folder): InputNavigationItem => ({
|
|
37
|
+
type: "category",
|
|
38
|
+
label: folderLabel(folder),
|
|
39
|
+
collapsible: true,
|
|
40
|
+
collapsed: false,
|
|
41
|
+
items: toItems(
|
|
42
|
+
branch.folders.get(folder) ?? emptyBranch(),
|
|
43
|
+
`${prefix}${folder}/`,
|
|
44
|
+
),
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The sidebar for hosted docs, made from the pages themselves: the index page first, then the other pages by
|
|
52
|
+
* name, then one section per folder. `routes` are the docs plugin's route paths (`/overview`, `/guides/style`).
|
|
53
|
+
* Without pages there is nothing to navigate, so the result is empty.
|
|
54
|
+
*/
|
|
55
|
+
export function docsNavigation(routes: string[]): InputNavigationItem[] {
|
|
56
|
+
const root = emptyBranch();
|
|
57
|
+
for (const route of routes) {
|
|
58
|
+
const parts = route.split("/").filter(Boolean);
|
|
59
|
+
const page = parts.pop();
|
|
60
|
+
if (!page) continue;
|
|
61
|
+
const branch = parts.reduce((current, folder) => {
|
|
62
|
+
const next = current.folders.get(folder) ?? emptyBranch();
|
|
63
|
+
current.folders.set(folder, next);
|
|
64
|
+
return next;
|
|
65
|
+
}, root);
|
|
66
|
+
branch.pages.add(page);
|
|
67
|
+
}
|
|
68
|
+
const items = toItems(root, "");
|
|
69
|
+
return items.length === 0
|
|
70
|
+
? []
|
|
71
|
+
: [{ type: "category", label: DOCS_NAVIGATION_LABEL, items }];
|
|
72
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { glob } from "glob";
|
|
3
|
+
import globParent from "glob-parent";
|
|
4
|
+
import { readFrontmatter } from "../../lib/util/readFrontmatter.js";
|
|
5
|
+
|
|
6
|
+
export type HomeRedirect = { from: string; to: string };
|
|
7
|
+
|
|
8
|
+
const IGNORED = ["**/node_modules/**", "**/dist/**", "**/.git/**"];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The routes the docs plugin gives the pages these globs match: the path inside the glob's folder, without the
|
|
12
|
+
* extension. Missing folders and negated globs are fine, they just match nothing.
|
|
13
|
+
*/
|
|
14
|
+
export async function docRoutes(
|
|
15
|
+
patterns: string[],
|
|
16
|
+
options: { includeDrafts?: boolean } = {},
|
|
17
|
+
): Promise<string[]> {
|
|
18
|
+
const excluded = patterns
|
|
19
|
+
.filter((pattern) => pattern.startsWith("!"))
|
|
20
|
+
.map((pattern) => pattern.slice(1));
|
|
21
|
+
const included = patterns.filter((pattern) => !pattern.startsWith("!"));
|
|
22
|
+
|
|
23
|
+
const routes = await Promise.all(
|
|
24
|
+
included.map(async (pattern) => {
|
|
25
|
+
const files = await glob(pattern, {
|
|
26
|
+
absolute: true,
|
|
27
|
+
posix: true,
|
|
28
|
+
ignore: [...IGNORED, ...excluded],
|
|
29
|
+
});
|
|
30
|
+
const parent = globParent(pattern);
|
|
31
|
+
// The docs plugin leaves draft pages out of a production build, so the routes must too.
|
|
32
|
+
const published = options.includeDrafts
|
|
33
|
+
? files
|
|
34
|
+
: (
|
|
35
|
+
await Promise.all(
|
|
36
|
+
files.map(async (file) => ({
|
|
37
|
+
file,
|
|
38
|
+
draft: (await readFrontmatter(file)).data.draft === true,
|
|
39
|
+
})),
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
.filter(({ draft }) => !draft)
|
|
43
|
+
.map(({ file }) => file);
|
|
44
|
+
return published.map(
|
|
45
|
+
(file) =>
|
|
46
|
+
`/${path.posix.relative(parent, file).replace(/\.mdx?$/, "")}`,
|
|
47
|
+
);
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
return [...new Set(routes.flat())].sort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The page a visitor lands on when the site has no home page: the top-most page, then the first by name. */
|
|
54
|
+
export function pickLandingRoute(routes: string[]): string | undefined {
|
|
55
|
+
const depth = (route: string) => route.split("/").length;
|
|
56
|
+
return [...routes].sort(
|
|
57
|
+
(a, b) => depth(a) - depth(b) || a.localeCompare(b),
|
|
58
|
+
)[0];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type HomeRedirectInput = {
|
|
62
|
+
/** APItoGo builds the docs pages. */
|
|
63
|
+
hostsDocs: boolean;
|
|
64
|
+
/** Routes of those pages (see `docRoutes`). */
|
|
65
|
+
docRoutes: string[];
|
|
66
|
+
/** Where the docs live when someone else hosts them. */
|
|
67
|
+
externalDocsUrl?: string;
|
|
68
|
+
/** The portal has an API reference page, at this path. */
|
|
69
|
+
apiReferencePath?: string;
|
|
70
|
+
/** The portal has console pages (sign-in, keys, plans). */
|
|
71
|
+
userPanel: boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Where `/` should go when the portal has no page of its own there. Without this a visitor of the home page sees
|
|
76
|
+
* "Page not found".
|
|
77
|
+
*
|
|
78
|
+
* - Hosted docs without an index page: the first page, so the team's own docs never need an extra file.
|
|
79
|
+
* - Hosted docs without any page: the API reference, when there is one.
|
|
80
|
+
* - External docs and no console: the external docs site, as the portal has nothing else to show.
|
|
81
|
+
*/
|
|
82
|
+
export function homeRedirect(
|
|
83
|
+
input: HomeRedirectInput,
|
|
84
|
+
): HomeRedirect | undefined {
|
|
85
|
+
if (input.hostsDocs) {
|
|
86
|
+
if (input.docRoutes.some((route) => route === "/" || route === "/index"))
|
|
87
|
+
return undefined;
|
|
88
|
+
const landing = pickLandingRoute(input.docRoutes);
|
|
89
|
+
if (landing) return { from: "/", to: landing };
|
|
90
|
+
return input.apiReferencePath
|
|
91
|
+
? { from: "/", to: input.apiReferencePath }
|
|
92
|
+
: undefined;
|
|
93
|
+
}
|
|
94
|
+
if (input.externalDocsUrl && !input.userPanel) {
|
|
95
|
+
return { from: "/", to: input.externalDocsUrl };
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
package/src/config/v2/wrapper.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { docsNavigation } from "./docs-navigation.js";
|
|
3
4
|
import { ConfigError, type ConfigIssue } from "./errors.js";
|
|
4
5
|
import {
|
|
5
6
|
ReferenceSpecError,
|
|
6
7
|
renderReferenceSpec,
|
|
7
8
|
} from "./gateway-reference.js";
|
|
9
|
+
import { docRoutes, type HomeRedirect, homeRedirect } from "./home-redirect.js";
|
|
8
10
|
import {
|
|
9
11
|
hasCapability,
|
|
10
12
|
missingManifestServices,
|
|
@@ -243,8 +245,23 @@ export function buildSiteConfig(
|
|
|
243
245
|
resolved: ResolvedConfigV2,
|
|
244
246
|
plan: PortalPlan,
|
|
245
247
|
theme?: unknown,
|
|
248
|
+
extras: { redirects?: HomeRedirect[]; docsNavigation?: unknown[] } = {},
|
|
246
249
|
): Record<string, unknown> {
|
|
247
250
|
const portal = resolved.portal;
|
|
251
|
+
const redirects = extras.redirects ?? [];
|
|
252
|
+
// One header tab for the docs sidebar, and the API reference as a link next to it.
|
|
253
|
+
const navigation = [
|
|
254
|
+
...(extras.docsNavigation ?? []),
|
|
255
|
+
...(plan.apiReference
|
|
256
|
+
? [
|
|
257
|
+
{
|
|
258
|
+
type: "link",
|
|
259
|
+
to: `/${API_REFERENCE_PATH}`,
|
|
260
|
+
label: "API Reference",
|
|
261
|
+
},
|
|
262
|
+
]
|
|
263
|
+
: []),
|
|
264
|
+
];
|
|
248
265
|
return {
|
|
249
266
|
site: {
|
|
250
267
|
title: portal?.name ?? resolved.project.name,
|
|
@@ -274,15 +291,10 @@ export function buildSiteConfig(
|
|
|
274
291
|
path: API_REFERENCE_PATH,
|
|
275
292
|
},
|
|
276
293
|
],
|
|
277
|
-
navigation: [
|
|
278
|
-
{
|
|
279
|
-
type: "link",
|
|
280
|
-
to: `/${API_REFERENCE_PATH}`,
|
|
281
|
-
label: "API Reference",
|
|
282
|
-
},
|
|
283
|
-
],
|
|
284
294
|
}
|
|
285
295
|
: {}),
|
|
296
|
+
...(navigation.length > 0 ? { navigation } : {}),
|
|
297
|
+
...(redirects.length > 0 ? { redirects } : {}),
|
|
286
298
|
...(theme !== undefined ? { theme } : {}),
|
|
287
299
|
...(plan.auth ? { authentication: { type: "openid", ...plan.auth } } : {}),
|
|
288
300
|
...(plan.preview ? { preview: true } : {}),
|
|
@@ -430,7 +442,27 @@ export async function preparePortal(
|
|
|
430
442
|
});
|
|
431
443
|
}
|
|
432
444
|
}
|
|
433
|
-
const
|
|
445
|
+
const hostedDocRoutes = plan.docs
|
|
446
|
+
? await docRoutes(profile.docs.content, {
|
|
447
|
+
includeDrafts: mode === "development",
|
|
448
|
+
})
|
|
449
|
+
: [];
|
|
450
|
+
const home = homeRedirect({
|
|
451
|
+
hostsDocs: plan.docs,
|
|
452
|
+
docRoutes: hostedDocRoutes,
|
|
453
|
+
externalDocsUrl: profile.docs.url,
|
|
454
|
+
apiReferencePath: plan.apiReference ? `/${API_REFERENCE_PATH}` : undefined,
|
|
455
|
+
userPanel: plan.userPanel,
|
|
456
|
+
});
|
|
457
|
+
const siteConfig = buildSiteConfig(
|
|
458
|
+
resolved,
|
|
459
|
+
plan,
|
|
460
|
+
await readTheme(resolved),
|
|
461
|
+
{
|
|
462
|
+
redirects: home ? [home] : [],
|
|
463
|
+
docsNavigation: docsNavigation(hostedDocRoutes),
|
|
464
|
+
},
|
|
465
|
+
);
|
|
434
466
|
const wrapper = renderWrapper(plan);
|
|
435
467
|
const wrapperPath = path.join(resolved.state.generatedDir, WRAPPER_FILE);
|
|
436
468
|
if (options.write !== false) {
|