@lukoweb/apitogo 0.2.49 → 0.2.50
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 +817 -642
- package/docs/cli/shape.md +7 -7
- package/docs/configuration/make-config.mdx +13 -10
- package/docs/configuration/navigation.mdx +1 -1
- package/docs/guides/custom-pages.md +33 -4
- package/package.json +1 -1
- package/src/config/v2/docs-navigation.ts +48 -25
- package/src/config/v2/index.ts +1 -0
- package/src/config/v2/rules.ts +4 -1
- package/src/config/v2/tsx-pages.ts +100 -0
- package/src/config/v2/workspace.ts +5 -0
- package/src/config/v2/wrapper.ts +88 -9
package/docs/cli/shape.md
CHANGED
|
@@ -41,12 +41,12 @@ ready to go live.
|
|
|
41
41
|
On a TTY, bare `npx apitogo config` opens the wizard. Each choice is written immediately; the loop
|
|
42
42
|
continues until you type `done`:
|
|
43
43
|
|
|
44
|
-
| Choice | What it does
|
|
45
|
-
| --------- |
|
|
46
|
-
| `backend` | Set `gateway.origin`: API code folder, runtime and runtime version
|
|
47
|
-
| `openapi` | Add an OpenAPI file to `gateway.specs`
|
|
48
|
-
| `page` | Create `apitogo/portal/pages/<slug>.
|
|
49
|
-
| `plan` | Add a plan to `apitogo/plans.json` (title, price, rate limit, quota)
|
|
44
|
+
| Choice | What it does |
|
|
45
|
+
| --------- | --------------------------------------------------------------------------------------- |
|
|
46
|
+
| `backend` | Set `gateway.origin`: API code folder, runtime and runtime version |
|
|
47
|
+
| `openapi` | Add an OpenAPI file to `gateway.specs` |
|
|
48
|
+
| `page` | Create the React page `apitogo/portal/pages/<slug>.tsx` (the sidebar follows the files) |
|
|
49
|
+
| `plan` | Add a plan to `apitogo/plans.json` (title, price, rate limit, quota) |
|
|
50
50
|
|
|
51
51
|
Fonts and colors are **not** prompted. See [Colors & theme](../customization/colors-theme.mdx).
|
|
52
52
|
|
|
@@ -88,7 +88,7 @@ After `npx apitogo config`, edit the manifest and site files directly:
|
|
|
88
88
|
|
|
89
89
|
- **Services, backend, OpenAPI files** — `apitogo.config.json`
|
|
90
90
|
- **Plans** — `apitogo/plans.json`
|
|
91
|
-
- **Docs pages** — `apitogo/portal/pages/**/*.mdx`
|
|
91
|
+
- **Docs pages** — `apitogo/portal/pages/**/*.{md,mdx,tsx}` (`.tsx` files are React pages)
|
|
92
92
|
- **Home page** — the file in `portal.landing` (for example
|
|
93
93
|
`apitogo/portal/landing/LandingPage.tsx`)
|
|
94
94
|
|
|
@@ -19,16 +19,18 @@ version 1 config, it stops and points to `apitogo create`.
|
|
|
19
19
|
|
|
20
20
|
## What goes where
|
|
21
21
|
|
|
22
|
-
| You change | Where it is written
|
|
23
|
-
| ------------------- |
|
|
24
|
-
| Portal display name | `portal.name` in `apitogo.config.json`
|
|
25
|
-
| API code (backend) | `gateway.origin` (`mode: managed`, `source`, `runtime`, version)
|
|
26
|
-
| OpenAPI file | `gateway.specs` in `apitogo.config.json`
|
|
27
|
-
| Docs page | `apitogo/portal/pages/<slug>.
|
|
28
|
-
| Plan | `apitogo/plans.json` (or the file in `plans.file`)
|
|
22
|
+
| You change | Where it is written |
|
|
23
|
+
| ------------------- | ------------------------------------------------------------------ |
|
|
24
|
+
| Portal display name | `portal.name` in `apitogo.config.json` |
|
|
25
|
+
| API code (backend) | `gateway.origin` (`mode: managed`, `source`, `runtime`, version) |
|
|
26
|
+
| OpenAPI file | `gateway.specs` in `apitogo.config.json` |
|
|
27
|
+
| Docs page | `apitogo/portal/pages/<slug>.tsx`, a React page (config unchanged) |
|
|
28
|
+
| Plan | `apitogo/plans.json` (or the file in `plans.file`) |
|
|
29
29
|
|
|
30
30
|
The portal builds its sidebar from the pages folder, so adding a page needs no navigation entry. A
|
|
31
|
-
page in `guides/quickstart.
|
|
31
|
+
page in `guides/quickstart.tsx` shows under a **Guides** section at `/guides/quickstart`. New pages
|
|
32
|
+
are React components by default; pass `--page-format mdx` for a Markdown page. See
|
|
33
|
+
[Custom pages](../guides/custom-pages.md).
|
|
32
34
|
|
|
33
35
|
Changing a gateway or plan needs that service in the project. Add it first with
|
|
34
36
|
`apitogo add gateway` or `apitogo add plans`.
|
|
@@ -40,7 +42,7 @@ On a TTY, bare `npx apitogo config` opens the wizard. Each answer is applied at
|
|
|
40
42
|
|
|
41
43
|
- **`backend`**: folder of your API code, runtime (`nodejs` / `python` / `dotnet`) and version
|
|
42
44
|
- **`openapi`**: an OpenAPI file to add to `gateway.specs`
|
|
43
|
-
- **`page`**: page title and URL path; writes `apitogo/portal/pages/<slug>.
|
|
45
|
+
- **`page`**: page title and URL path; writes the React page `apitogo/portal/pages/<slug>.tsx`
|
|
44
46
|
- **`plan`**: title, fixed or custom price, rate limit per minute, quota per 30 days; added to
|
|
45
47
|
`plans.json`
|
|
46
48
|
|
|
@@ -58,7 +60,8 @@ plans also need Starter or Professional platform hosting.
|
|
|
58
60
|
| `--runtime <name>` | Runtime of `--source`: `nodejs` (default), `python` or `dotnet` |
|
|
59
61
|
| `--runtime-version <v>` | Runtime version, for example `NODE_22_LTS` |
|
|
60
62
|
| `--openapi <file>` | Add an OpenAPI file to `gateway.specs` |
|
|
61
|
-
| `--page-slug <path>` | Add a page at `apitogo/portal/pages/<path>.
|
|
63
|
+
| `--page-slug <path>` | Add a page at `apitogo/portal/pages/<path>.tsx` |
|
|
64
|
+
| `--page-format <tsx\|mdx>` | Format of a new page: `tsx` (React, the default) or `mdx` |
|
|
62
65
|
| `--page-title <title>` | Title of the page (required when adding) |
|
|
63
66
|
| `--page-content <markdown>` | Markdown body of the page |
|
|
64
67
|
| `--page-update` | Change an existing page instead of adding one |
|
|
@@ -61,7 +61,7 @@ one of several types. At the simplest level you may only have links and categori
|
|
|
61
61
|
In a config version 2 project, the portal builds the sidebar from the pages under
|
|
62
62
|
`apitogo/portal/pages/`: the index page first, then the other pages by name, then one section per
|
|
63
63
|
folder. There is no `navigation` key to maintain. Add a page with **`npx apitogo config`** (see
|
|
64
|
-
**[Make config (CLI)](./make-config.mdx)**) or by creating the `.md`
|
|
64
|
+
**[Make config (CLI)](./make-config.mdx)**) or by creating the `.md`, `.mdx` or `.tsx` (React) file.
|
|
65
65
|
|
|
66
66
|
## Navigation Items
|
|
67
67
|
|
|
@@ -11,16 +11,45 @@ components that APIToGo already has such as buttons, links and headers.
|
|
|
11
11
|
|
|
12
12
|
Start by creating the page you want to add.
|
|
13
13
|
|
|
14
|
+
## React pages in a project made by `apitogo create`
|
|
15
|
+
|
|
16
|
+
Put a `.tsx` file next to your Markdown pages, in `apitogo/portal/pages/` (or any folder in
|
|
17
|
+
`portal.docs.content`). Its default export is the page, and `export const title` is its label in the
|
|
18
|
+
sidebar. The page's address follows its path, like a Markdown page: `guides/quickstart.tsx` is
|
|
19
|
+
`/guides/quickstart`, under a **Guides** section.
|
|
20
|
+
|
|
21
|
+
```tsx title="apitogo/portal/pages/guides/quickstart.tsx"
|
|
22
|
+
import { useState } from "react";
|
|
23
|
+
import { Button, Heading, Typography } from "@lukoweb/apitogo/components";
|
|
24
|
+
|
|
25
|
+
export const title = "Quickstart";
|
|
26
|
+
|
|
27
|
+
export default function QuickstartPage() {
|
|
28
|
+
const [count, setCount] = useState(0);
|
|
29
|
+
return (
|
|
30
|
+
<Typography>
|
|
31
|
+
<Heading level={1}>{title}</Heading>
|
|
32
|
+
<Button onClick={() => setCount(count + 1)}>Clicked {count} times</Button>
|
|
33
|
+
</Typography>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- A file or folder whose name starts with `_` is not a page, so keep shared components there, for
|
|
39
|
+
example `apitogo/portal/pages/_components/Chart.tsx`. Test and story files are not pages either.
|
|
40
|
+
- When a Markdown page and a React page have the same address, the Markdown page wins.
|
|
41
|
+
- Nothing in `apitogo.config.json` changes: the sidebar is built from the files.
|
|
42
|
+
|
|
14
43
|
## CLI scaffold
|
|
15
44
|
|
|
16
|
-
`npx apitogo config`
|
|
17
|
-
`page` in the wizard loop, or pass `--page-slug` and `--page-title`.
|
|
18
|
-
|
|
45
|
+
`npx apitogo config` creates a starter React page at `apitogo/portal/pages/<slug>.tsx`. Choose
|
|
46
|
+
`page` in the wizard loop, or pass `--page-slug` and `--page-title`. Add `--page-format mdx` for a
|
|
47
|
+
Markdown page instead.
|
|
19
48
|
|
|
20
49
|
Predefined UI components (Callout, Card, Button, Heading, and more) are documented under
|
|
21
50
|
[/docs/components](/docs/components).
|
|
22
51
|
|
|
23
|
-
## Setup a custom page
|
|
52
|
+
## Setup a custom page (version 1 projects)
|
|
24
53
|
|
|
25
54
|
Each custom page is a page component of its own and lives in a `src` directory at the root of your
|
|
26
55
|
project. Let's create the `<MyCustomPage />` component as an example.
|
package/package.json
CHANGED
|
@@ -3,9 +3,13 @@ import type { InputNavigationItem } from "../validators/InputNavigationSchema.js
|
|
|
3
3
|
/** Label of the one header tab that holds the sidebar made from the docs pages. */
|
|
4
4
|
export const DOCS_NAVIGATION_LABEL = "Documentation";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/** A React page in the sidebar. `element` is the placeholder the generated wrapper replaces with the component. */
|
|
7
|
+
export type CustomDocsPage = { route: string; label: string; element: string };
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
type Page = { kind: "doc" } | ({ kind: "custom" } & CustomDocsPage);
|
|
10
|
+
type Branch = { pages: Map<string, Page>; folders: Map<string, Branch> };
|
|
11
|
+
|
|
12
|
+
const emptyBranch = (): Branch => ({ pages: new Map(), folders: new Map() });
|
|
9
13
|
|
|
10
14
|
/** "getting-started" becomes "Getting started". */
|
|
11
15
|
export function folderLabel(name: string): string {
|
|
@@ -16,22 +20,28 @@ export function folderLabel(name: string): string {
|
|
|
16
20
|
const byName = (a: string, b: string) => a.localeCompare(b);
|
|
17
21
|
|
|
18
22
|
const toItems = (branch: Branch, prefix: string): InputNavigationItem[] => {
|
|
19
|
-
const
|
|
23
|
+
const names = [...branch.pages.keys()].sort((a, b) =>
|
|
20
24
|
a === "index" ? -1 : b === "index" ? 1 : byName(a, b),
|
|
21
25
|
);
|
|
22
26
|
const folders = [...branch.folders.keys()].sort(byName);
|
|
27
|
+
const folderPath = prefix === "" ? "/" : `/${prefix.slice(0, -1)}`;
|
|
23
28
|
return [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
(page)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
...names.map((name): InputNavigationItem => {
|
|
30
|
+
const page = branch.pages.get(name) as Page;
|
|
31
|
+
if (page.kind === "custom") {
|
|
32
|
+
return {
|
|
33
|
+
type: "custom-page",
|
|
34
|
+
// The index page of a folder is the folder's own address.
|
|
35
|
+
path: name === "index" ? folderPath : page.route,
|
|
36
|
+
label: page.label,
|
|
37
|
+
element: page.element,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// The index page of a folder is the folder's own address, so the sidebar also shows on `/`.
|
|
41
|
+
return name === "index"
|
|
42
|
+
? { type: "doc", file: `${prefix}${name}`, path: folderPath }
|
|
43
|
+
: `${prefix}${name}`;
|
|
44
|
+
}),
|
|
35
45
|
...folders.map(
|
|
36
46
|
(folder): InputNavigationItem => ({
|
|
37
47
|
type: "category",
|
|
@@ -47,23 +57,36 @@ const toItems = (branch: Branch, prefix: string): InputNavigationItem[] => {
|
|
|
47
57
|
];
|
|
48
58
|
};
|
|
49
59
|
|
|
60
|
+
const branchOf = (root: Branch, route: string) => {
|
|
61
|
+
const parts = route.split("/").filter(Boolean);
|
|
62
|
+
const page = parts.pop();
|
|
63
|
+
const branch = parts.reduce((current, folder) => {
|
|
64
|
+
const next = current.folders.get(folder) ?? emptyBranch();
|
|
65
|
+
current.folders.set(folder, next);
|
|
66
|
+
return next;
|
|
67
|
+
}, root);
|
|
68
|
+
return { branch, page };
|
|
69
|
+
};
|
|
70
|
+
|
|
50
71
|
/**
|
|
51
72
|
* 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`)
|
|
73
|
+
* name, then one section per folder. `routes` are the docs plugin's route paths (`/overview`, `/guides/style`);
|
|
74
|
+
* `customPages` are React pages placed the same way. A Markdown page wins over a React page at the same address.
|
|
53
75
|
* Without pages there is nothing to navigate, so the result is empty.
|
|
54
76
|
*/
|
|
55
|
-
export function docsNavigation(
|
|
77
|
+
export function docsNavigation(
|
|
78
|
+
routes: string[],
|
|
79
|
+
customPages: readonly CustomDocsPage[] = [],
|
|
80
|
+
): InputNavigationItem[] {
|
|
56
81
|
const root = emptyBranch();
|
|
57
82
|
for (const route of routes) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}, root);
|
|
66
|
-
branch.pages.add(page);
|
|
83
|
+
const { branch, page } = branchOf(root, route);
|
|
84
|
+
if (page) branch.pages.set(page, { kind: "doc" });
|
|
85
|
+
}
|
|
86
|
+
for (const custom of customPages) {
|
|
87
|
+
const { branch, page } = branchOf(root, custom.route);
|
|
88
|
+
if (page && !branch.pages.has(page))
|
|
89
|
+
branch.pages.set(page, { kind: "custom", ...custom });
|
|
67
90
|
}
|
|
68
91
|
const items = toItems(root, "");
|
|
69
92
|
return items.length === 0
|
package/src/config/v2/index.ts
CHANGED
|
@@ -101,6 +101,7 @@ export {
|
|
|
101
101
|
requiredPackages,
|
|
102
102
|
WRAPPER_FILE,
|
|
103
103
|
} from "./wrapper.js";
|
|
104
|
+
export { findTsxPages, type TsxPage, tsxPagePatterns } from "./tsx-pages.js";
|
|
104
105
|
export { materializeWorkspace, portalArtifactHash } from "./workspace.js";
|
|
105
106
|
export {
|
|
106
107
|
type AddOptions,
|
package/src/config/v2/rules.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { billingNeeded, stripeKeySecretName } from "./billing.js";
|
|
|
6
6
|
import type { ConfigIssue } from "./errors.js";
|
|
7
7
|
import { type ResolvedConfigV2, toGlobPath } from "./resolve.js";
|
|
8
8
|
import type { ConfigV2 } from "./schema.js";
|
|
9
|
+
import { findTsxPages } from "./tsx-pages.js";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* The rules of `apitogo check` that need no network (Reference, section 2). Online rules (provider capabilities,
|
|
@@ -277,6 +278,8 @@ async function docsContentRule(
|
|
|
277
278
|
? await glob(include, { nodir: true, ignore, absolute: true })
|
|
278
279
|
: [];
|
|
279
280
|
if (matches.length > 0) return;
|
|
281
|
+
// React pages (.tsx) next to the docs count too.
|
|
282
|
+
if ((await findTsxPages(patterns)).length > 0) return;
|
|
280
283
|
|
|
281
284
|
const shown = config.portal?.docs?.content ?? [
|
|
282
285
|
path.relative(resolved.configDir, patterns[0] ?? ""),
|
|
@@ -285,7 +288,7 @@ async function docsContentRule(
|
|
|
285
288
|
code: "PORTAL_DOCS_EMPTY",
|
|
286
289
|
path: "portal.docs",
|
|
287
290
|
message: `portal.docs matched 0 files (${shown.join(", ")}). The site will only contain the API reference.`,
|
|
288
|
-
hint: "Add .md or .
|
|
291
|
+
hint: "Add .md, .mdx or .tsx pages, or point docs.content at your existing docs.",
|
|
289
292
|
});
|
|
290
293
|
}
|
|
291
294
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { glob } from "glob";
|
|
4
|
+
import globParent from "glob-parent";
|
|
5
|
+
import { folderLabel } from "./docs-navigation.js";
|
|
6
|
+
import { toGlobPath } from "./resolve.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* React pages next to the Markdown docs: a `.tsx` file in a docs folder is a page whose default export is the page
|
|
10
|
+
* component. Its address follows the same rule as a Markdown page (the path inside the folder, without the extension),
|
|
11
|
+
* so `pages/guides/quickstart.tsx` is `/guides/quickstart`. A file or folder whose name starts with `_` is a helper
|
|
12
|
+
* (shared components, hooks), not a page.
|
|
13
|
+
*/
|
|
14
|
+
export type TsxPage = {
|
|
15
|
+
/** `/guides/quickstart`. */
|
|
16
|
+
route: string;
|
|
17
|
+
/** Absolute path of the component file. */
|
|
18
|
+
file: string;
|
|
19
|
+
/** Sidebar label: `export const title = "..."` in the file, or the file name. */
|
|
20
|
+
label: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const IGNORED = ["**/node_modules/**", "**/dist/**", "**/.git/**"];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A helper next to the pages, not a page: anything under a name that starts with `_`, tests and stories. Checked on
|
|
27
|
+
* the path inside the docs folder (the glob library's `ignore` does not match absolute Windows paths).
|
|
28
|
+
*/
|
|
29
|
+
export function isTsxHelper(relative: string): boolean {
|
|
30
|
+
return (
|
|
31
|
+
relative.split("/").some((part) => part.startsWith("_")) ||
|
|
32
|
+
/\.(test|spec|stories)\.tsx$/.test(relative) ||
|
|
33
|
+
relative.split("/").includes("node_modules")
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The `.tsx` patterns for the docs content globs: every file below each glob's folder. Negated globs stay as they are. */
|
|
38
|
+
export function tsxPagePatterns(contentGlobs: readonly string[]): string[] {
|
|
39
|
+
const globs = contentGlobs.map(toGlobPath);
|
|
40
|
+
const included = globs.filter((pattern) => !pattern.startsWith("!"));
|
|
41
|
+
const excluded = globs.filter((pattern) => pattern.startsWith("!"));
|
|
42
|
+
return [
|
|
43
|
+
...new Set(
|
|
44
|
+
included.map((pattern) =>
|
|
45
|
+
path.posix.join(globParent(pattern), "**", "*.tsx"),
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
...excluded,
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const TITLE =
|
|
53
|
+
/export\s+const\s+title\s*(?::\s*string\s*)?=\s*(["'`])((?:\\.|(?!\1).)*)\1/;
|
|
54
|
+
|
|
55
|
+
/** The label a page shows in the sidebar. */
|
|
56
|
+
export function tsxPageLabel(source: string, file: string): string {
|
|
57
|
+
const match = TITLE.exec(source);
|
|
58
|
+
if (match?.[2]) return match[2].replace(/\\(.)/g, "$1");
|
|
59
|
+
const name = path.basename(file, ".tsx");
|
|
60
|
+
return folderLabel(
|
|
61
|
+
name === "index" ? path.basename(path.dirname(file)) : name,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The React pages the docs content globs reach, sorted by address. */
|
|
66
|
+
export async function findTsxPages(
|
|
67
|
+
contentGlobs: readonly string[],
|
|
68
|
+
): Promise<TsxPage[]> {
|
|
69
|
+
const patterns = tsxPagePatterns(contentGlobs);
|
|
70
|
+
const ignore = [
|
|
71
|
+
...IGNORED,
|
|
72
|
+
...patterns
|
|
73
|
+
.filter((pattern) => pattern.startsWith("!"))
|
|
74
|
+
.map((pattern) => pattern.slice(1)),
|
|
75
|
+
];
|
|
76
|
+
const found = new Map<string, TsxPage>();
|
|
77
|
+
for (const pattern of patterns.filter((p) => !p.startsWith("!"))) {
|
|
78
|
+
const parent = globParent(pattern);
|
|
79
|
+
const files = await glob(pattern, {
|
|
80
|
+
absolute: true,
|
|
81
|
+
posix: true,
|
|
82
|
+
nodir: true,
|
|
83
|
+
ignore,
|
|
84
|
+
});
|
|
85
|
+
for (const raw of files.sort()) {
|
|
86
|
+
// On Windows the glob library answers with the extended form `//?/C:/...`, which would not relate to `parent`.
|
|
87
|
+
const file = raw.replace(/^\/\/\?\//, "");
|
|
88
|
+
const relative = path.posix.relative(parent, file);
|
|
89
|
+
if (isTsxHelper(relative)) continue;
|
|
90
|
+
const route = `/${relative.replace(/\.tsx$/, "")}`;
|
|
91
|
+
if (found.has(route)) continue;
|
|
92
|
+
const source = await readFile(file, "utf8").catch(() => "");
|
|
93
|
+
found.set(route, { route, file, label: tsxPageLabel(source, file) });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return [...found.values()].sort((a, b) => a.route.localeCompare(b.route));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The placeholder a sidebar item carries until the generated wrapper puts the component in its place. */
|
|
100
|
+
export const TSX_PAGE_ELEMENT_PREFIX = "apitogo:tsx-page:";
|
|
@@ -5,6 +5,7 @@ import { glob } from "glob";
|
|
|
5
5
|
import { ensureStateDir } from "./layout.js";
|
|
6
6
|
import { linkDirectory } from "./link-directory.js";
|
|
7
7
|
import type { ResolvedConfigV2 } from "./resolve.js";
|
|
8
|
+
import { tsxPagePatterns } from "./tsx-pages.js";
|
|
8
9
|
import { CONTENT_LINK, type PreparedPortal } from "./wrapper.js";
|
|
9
10
|
|
|
10
11
|
const exists = (file: string) =>
|
|
@@ -92,6 +93,10 @@ export async function portalArtifactHash(
|
|
|
92
93
|
const portal = resolved.portal;
|
|
93
94
|
const patterns = [
|
|
94
95
|
...(portal?.docs.content ?? []),
|
|
96
|
+
// React docs pages and the helpers next to them are imported by the wrapper, so their contents decide the build too.
|
|
97
|
+
...(portal?.docs.provider === "apitogo"
|
|
98
|
+
? tsxPagePatterns(portal.docs.content)
|
|
99
|
+
: []),
|
|
95
100
|
...(resolved.gateway?.specs ?? []),
|
|
96
101
|
...(portal
|
|
97
102
|
? [portal.theme, path.join(portal.dir, "public", "**", "*")]
|
package/src/config/v2/wrapper.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { type PortalProfile, portalProfile } from "./portal-profile.js";
|
|
24
24
|
import { protectedRoutesFor } from "./protected-routes.js";
|
|
25
25
|
import type { ResolvedConfigV2 } from "./resolve.js";
|
|
26
|
+
import { findTsxPages, TSX_PAGE_ELEMENT_PREFIX } from "./tsx-pages.js";
|
|
26
27
|
|
|
27
28
|
export const WRAPPER_FILE = "apitogo.config.tsx";
|
|
28
29
|
|
|
@@ -424,8 +425,17 @@ function authenticationFor(plan: PortalPlan): Record<string, unknown> {
|
|
|
424
425
|
export const PREVIEW_BANNER_MESSAGE =
|
|
425
426
|
"Preview mode: sign in with demo@apitogo.local / password to try the console. Run `apitogo deploy` for the real services.";
|
|
426
427
|
|
|
427
|
-
/**
|
|
428
|
-
export
|
|
428
|
+
/** A React docs page the wrapper imports: `importPath` is relative to the wrapper, through the `content` link. */
|
|
429
|
+
export type WrapperPage = { id: string; importPath: string };
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The wrapper source. Imports exist only for what the plan registers. React docs pages are imported here and put in
|
|
433
|
+
* place of their sidebar placeholders, since the site config is JSON and cannot hold a component.
|
|
434
|
+
*/
|
|
435
|
+
export function renderWrapper(
|
|
436
|
+
plan: PortalPlan,
|
|
437
|
+
pages: readonly WrapperPage[] = [],
|
|
438
|
+
): string {
|
|
429
439
|
const imports = [
|
|
430
440
|
'import { buildApitogoConfig } from "@lukoweb/apitogo/build-config";',
|
|
431
441
|
...(plan.plansPlugin
|
|
@@ -433,12 +443,41 @@ export function renderWrapper(plan: PortalPlan): string {
|
|
|
433
443
|
'import { devPortalBillingPlugin } from "@lukoweb/apitogo-plugin-dev-portal-billing";',
|
|
434
444
|
]
|
|
435
445
|
: []),
|
|
446
|
+
...(pages.length > 0 ? ['import { createElement } from "react";'] : []),
|
|
447
|
+
...pages.map(
|
|
448
|
+
(page, index) =>
|
|
449
|
+
`import TsxPage${index} from ${JSON.stringify(page.importPath)};`,
|
|
450
|
+
),
|
|
436
451
|
`import siteConfig from "./${SITE_CONFIG_FILE}";`,
|
|
437
452
|
];
|
|
438
453
|
const plugins = plan.plansPlugin
|
|
439
454
|
? `[devPortalBillingPlugin(${JSON.stringify(plan.plansPlugin)})]`
|
|
440
455
|
: "[]";
|
|
441
456
|
|
|
457
|
+
const pageLines =
|
|
458
|
+
pages.length === 0
|
|
459
|
+
? []
|
|
460
|
+
: [
|
|
461
|
+
"",
|
|
462
|
+
"// React docs pages: each sidebar placeholder becomes the page's component.",
|
|
463
|
+
"const tsxPages: Record<string, any> = {",
|
|
464
|
+
...pages.map(
|
|
465
|
+
(page, index) => ` ${JSON.stringify(page.id)}: TsxPage${index},`,
|
|
466
|
+
),
|
|
467
|
+
"};",
|
|
468
|
+
"const withTsxPages = (items: any[]): any[] =>",
|
|
469
|
+
" items.map((item) => {",
|
|
470
|
+
' if (item === null || typeof item !== "object") return item;',
|
|
471
|
+
" const page =",
|
|
472
|
+
' typeof item.element === "string" ? tsxPages[item.element] : undefined;',
|
|
473
|
+
" return {",
|
|
474
|
+
" ...item,",
|
|
475
|
+
" ...(page ? { element: createElement(page) } : {}),",
|
|
476
|
+
" ...(Array.isArray(item.items) ? { items: withTsxPages(item.items) } : {}),",
|
|
477
|
+
" };",
|
|
478
|
+
" });",
|
|
479
|
+
];
|
|
480
|
+
|
|
442
481
|
return [
|
|
443
482
|
"// Generated by apitogo from apitogo.config.json and services.json. Do not edit: it is rewritten on every build.",
|
|
444
483
|
...(plan.preview
|
|
@@ -447,11 +486,28 @@ export function renderWrapper(plan: PortalPlan): string {
|
|
|
447
486
|
]
|
|
448
487
|
: []),
|
|
449
488
|
...imports,
|
|
489
|
+
...pageLines,
|
|
450
490
|
"",
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
491
|
+
// The components go in after buildApitogoConfig: it copies the config with structuredClone, which cannot copy a
|
|
492
|
+
// React element.
|
|
493
|
+
...(pages.length > 0
|
|
494
|
+
? [
|
|
495
|
+
"const config = buildApitogoConfig({",
|
|
496
|
+
" ...siteConfig,",
|
|
497
|
+
` plugins: ${plugins},`,
|
|
498
|
+
"});",
|
|
499
|
+
"",
|
|
500
|
+
"export default {",
|
|
501
|
+
" ...config,",
|
|
502
|
+
" navigation: withTsxPages((config as any).navigation ?? []),",
|
|
503
|
+
"};",
|
|
504
|
+
]
|
|
505
|
+
: [
|
|
506
|
+
"export default buildApitogoConfig({",
|
|
507
|
+
" ...siteConfig,",
|
|
508
|
+
` plugins: ${plugins},`,
|
|
509
|
+
"});",
|
|
510
|
+
]),
|
|
455
511
|
"",
|
|
456
512
|
].join("\n");
|
|
457
513
|
}
|
|
@@ -580,11 +636,27 @@ export async function preparePortal(
|
|
|
580
636
|
});
|
|
581
637
|
}
|
|
582
638
|
}
|
|
583
|
-
const
|
|
639
|
+
const markdownRoutes = plan.docs
|
|
584
640
|
? await docRoutes(profile.docs.content, {
|
|
585
641
|
includeDrafts: mode === "development",
|
|
586
642
|
})
|
|
587
643
|
: [];
|
|
644
|
+
// React pages next to the Markdown ones. A Markdown page wins over a React page at the same address.
|
|
645
|
+
const tsxPages = plan.docs
|
|
646
|
+
? (await findTsxPages(profile.docs.content)).filter(
|
|
647
|
+
(page) => !markdownRoutes.includes(page.route),
|
|
648
|
+
)
|
|
649
|
+
: [];
|
|
650
|
+
const wrapperPages: WrapperPage[] = tsxPages.map((page, index) => ({
|
|
651
|
+
id: `${TSX_PAGE_ELEMENT_PREFIX}${index}`,
|
|
652
|
+
importPath: `./${CONTENT_LINK}/${path
|
|
653
|
+
.relative(resolved.configDir, page.file)
|
|
654
|
+
.split(path.sep)
|
|
655
|
+
.join("/")}`,
|
|
656
|
+
}));
|
|
657
|
+
const hostedDocRoutes = [
|
|
658
|
+
...new Set([...markdownRoutes, ...tsxPages.map((page) => page.route)]),
|
|
659
|
+
].sort();
|
|
588
660
|
// The account pages' "Documentation" link goes to a page the portal really has, or is left out.
|
|
589
661
|
if (plan.plansPlugin && plan.docs) {
|
|
590
662
|
const landing = hostedDocRoutes.some(
|
|
@@ -617,7 +689,14 @@ export async function preparePortal(
|
|
|
617
689
|
await readTheme(resolved),
|
|
618
690
|
{
|
|
619
691
|
redirects: home ? [home] : [],
|
|
620
|
-
docsNavigation: docsNavigation(
|
|
692
|
+
docsNavigation: docsNavigation(
|
|
693
|
+
markdownRoutes,
|
|
694
|
+
tsxPages.map((page, index) => ({
|
|
695
|
+
route: page.route,
|
|
696
|
+
label: page.label,
|
|
697
|
+
element: wrapperPages[index]?.id ?? "",
|
|
698
|
+
})),
|
|
699
|
+
),
|
|
621
700
|
docsPath: pickLandingRoute(hostedDocRoutes),
|
|
622
701
|
demoPlans:
|
|
623
702
|
plan.demo && plan.plansPlugin
|
|
@@ -633,7 +712,7 @@ export async function preparePortal(
|
|
|
633
712
|
}),
|
|
634
713
|
},
|
|
635
714
|
);
|
|
636
|
-
const wrapper = renderWrapper(plan);
|
|
715
|
+
const wrapper = renderWrapper(plan, wrapperPages);
|
|
637
716
|
const wrapperPath = path.join(resolved.state.generatedDir, WRAPPER_FILE);
|
|
638
717
|
if (options.write !== false) {
|
|
639
718
|
await mkdir(path.dirname(wrapperPath), { recursive: true });
|