@lukoweb/apitogo 0.2.47 → 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 +1034 -761
- package/docs/cli/shape.md +11 -10
- package/docs/cli/walkthrough.md +14 -25
- package/docs/configuration/make-config.mdx +16 -12
- package/docs/configuration/navigation.mdx +1 -1
- package/docs/guides/custom-pages.md +33 -4
- package/package.json +1 -1
- package/src/config/v2/__fixtures__/scenario-a-docs-only.json +6 -6
- package/src/config/v2/__fixtures__/scenario-b-managed-origin.json +14 -14
- package/src/config/v2/__fixtures__/scenario-c-proxy-oidc.json +15 -15
- package/src/config/v2/__fixtures__/scenario-h-full-stack.json +28 -28
- package/src/config/v2/billing.ts +59 -0
- package/src/config/v2/docs-navigation.ts +95 -72
- package/src/config/v2/errors.ts +42 -42
- package/src/config/v2/gateway-reference.ts +220 -220
- package/src/config/v2/gateway-request.ts +123 -123
- package/src/config/v2/index.ts +8 -0
- package/src/config/v2/layout.ts +237 -237
- package/src/config/v2/load.ts +70 -70
- package/src/config/v2/parse.ts +214 -214
- package/src/config/v2/portal-profile.ts +88 -88
- package/src/config/v2/resolve.ts +6 -1
- package/src/config/v2/rules.ts +31 -16
- package/src/config/v2/services.ts +5 -20
- 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,18 +41,19 @@ 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
|
|
|
53
|
-
**Paid plans:** a plan
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
**Paid plans:** payments go through Stripe. Once a plan has a price, `npx apitogo deploy` asks for
|
|
54
|
+
your Stripe secret key (if the project has none yet) and stores it as a project secret. Free and
|
|
55
|
+
contact-us plans need no key. Paid plans also need **Starter** or **Professional** platform hosting;
|
|
56
|
+
**Free** cannot publish paid gateway plans or custom domains.
|
|
56
57
|
|
|
57
58
|
Full reference: [Make config (CLI)](../configuration/make-config.mdx) ·
|
|
58
59
|
[Gateway plans](../configuration/plans.md)
|
|
@@ -87,7 +88,7 @@ After `npx apitogo config`, edit the manifest and site files directly:
|
|
|
87
88
|
|
|
88
89
|
- **Services, backend, OpenAPI files** — `apitogo.config.json`
|
|
89
90
|
- **Plans** — `apitogo/plans.json`
|
|
90
|
-
- **Docs pages** — `apitogo/portal/pages/**/*.mdx`
|
|
91
|
+
- **Docs pages** — `apitogo/portal/pages/**/*.{md,mdx,tsx}` (`.tsx` files are React pages)
|
|
91
92
|
- **Home page** — the file in `portal.landing` (for example
|
|
92
93
|
`apitogo/portal/landing/LandingPage.tsx`)
|
|
93
94
|
|
package/docs/cli/walkthrough.md
CHANGED
|
@@ -37,7 +37,7 @@ below use the installed CLI through `npx apitogo`.
|
|
|
37
37
|
```bash
|
|
38
38
|
npx apitogo add identity
|
|
39
39
|
npx apitogo add gateway --origin proxy --url https://httpbin.org --auth identity --create-spec
|
|
40
|
-
npx apitogo add plans --template free
|
|
40
|
+
npx apitogo add plans --template free
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
| Service | What it gives you |
|
|
@@ -117,32 +117,21 @@ Professional plan with APItoGo, and your own Stripe account. Your customers pay
|
|
|
117
117
|
opens the platform's checkout for the hosting fee. A project that already exists on the Free plan
|
|
118
118
|
cannot be moved: create a new one. See
|
|
119
119
|
[Selling paid plans needs Starter or Professional](./commands.md#selling-paid-plans-needs-starter-or-professional).
|
|
120
|
-
2.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
```json
|
|
130
|
-
"plans": {
|
|
131
|
-
"billing": { "provider": "stripe", "apiKey": "secret:STRIPE_KEY" }
|
|
132
|
-
}
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
4. Give a plan a price in `plans.json`: `"priceAmount": 49`, `"priceCurrency": "usd"` and
|
|
136
|
-
`"billingPeriod": "month"`. Its limits cannot be higher than your platform plan allows. A plan
|
|
137
|
-
with a price is refused while billing is off, so nobody is offered a plan they cannot pay for.
|
|
138
|
-
5. Deploy: `npx apitogo deploy`. The output says whether Stripe is in test or live mode and prints
|
|
139
|
-
your portal's webhook address.
|
|
140
|
-
6. In Stripe, add that address as a webhook endpoint with the events `checkout.session.completed`,
|
|
120
|
+
2. Give a plan a price in `plans.json`: `"priceAmount": 49`, `"priceCurrency": "usd"` and
|
|
121
|
+
`"billingInterval": "month"`. Its limits cannot be higher than your platform plan allows.
|
|
122
|
+
Payments go through Stripe; nothing else in the config changes.
|
|
123
|
+
3. Deploy: `npx apitogo deploy`. Because a plan now has a price, deploy asks for your Stripe secret
|
|
124
|
+
key (it starts with `sk_test_` while you test) and stores it as the project secret
|
|
125
|
+
`STRIPE_API_KEY`, so it stays out of your files. You can also store it beforehand with
|
|
126
|
+
`npx apitogo secrets set STRIPE_API_KEY`. The output says whether Stripe is in test or live mode
|
|
127
|
+
and prints your portal's webhook address.
|
|
128
|
+
4. In Stripe, add that address as a webhook endpoint with the events `checkout.session.completed`,
|
|
141
129
|
`customer.subscription.updated`, `customer.subscription.deleted` and `invoice.payment_failed`.
|
|
142
130
|
Copy the signing secret (it starts with `whsec_`), store it with
|
|
143
|
-
`npx apitogo secrets set STRIPE_WEBHOOK`, add
|
|
144
|
-
`
|
|
145
|
-
|
|
131
|
+
`npx apitogo secrets set STRIPE_WEBHOOK`, add
|
|
132
|
+
`"plans": { "billing": { "webhookSecret": "secret:STRIPE_WEBHOOK" } }` to `apitogo.config.json`,
|
|
133
|
+
and deploy again. Until then a payment does not activate the plan, because the portal cannot
|
|
134
|
+
confirm it came from Stripe.
|
|
146
135
|
|
|
147
136
|
While you use a test key, pay with [Stripe's test cards](https://docs.stripe.com/testing). No real
|
|
148
137
|
money moves. Never type a live key until you have tested the whole path.
|
|
@@ -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,12 +42,13 @@ 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
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
Payments go through Stripe. When you add a **paid** plan, `npx apitogo deploy` asks for your Stripe
|
|
50
|
+
secret key the first time (if the project has none yet). Free and contact-us plans need no key. Paid
|
|
51
|
+
plans also need Starter or Professional platform hosting.
|
|
49
52
|
|
|
50
53
|
## Options
|
|
51
54
|
|
|
@@ -57,7 +60,8 @@ will refuse it. Paid plans also need Starter or Professional platform hosting.
|
|
|
57
60
|
| `--runtime <name>` | Runtime of `--source`: `nodejs` (default), `python` or `dotnet` |
|
|
58
61
|
| `--runtime-version <v>` | Runtime version, for example `NODE_22_LTS` |
|
|
59
62
|
| `--openapi <file>` | Add an OpenAPI file to `gateway.specs` |
|
|
60
|
-
| `--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` |
|
|
61
65
|
| `--page-title <title>` | Title of the page (required when adding) |
|
|
62
66
|
| `--page-content <markdown>` | Markdown body of the page |
|
|
63
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
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 2,
|
|
3
|
-
"layout": "standalone",
|
|
4
|
-
"project": { "name": "my-docs" },
|
|
5
|
-
"portal": { "name": "My Docs" }
|
|
6
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"version": 2,
|
|
3
|
+
"layout": "standalone",
|
|
4
|
+
"project": { "name": "my-docs" },
|
|
5
|
+
"portal": { "name": "My Docs" }
|
|
6
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 2,
|
|
3
|
-
"layout": "embedded",
|
|
4
|
-
"gateway": {
|
|
5
|
-
"origin": {
|
|
6
|
-
"mode": "managed",
|
|
7
|
-
"source": ".",
|
|
8
|
-
"runtime": "nodejs",
|
|
9
|
-
"runtimeVersion": "NODE_22_LTS",
|
|
10
|
-
"exclude": ["apitogo/**", "node_modules/**"]
|
|
11
|
-
},
|
|
12
|
-
"specs": ["openapi.yaml"]
|
|
13
|
-
}
|
|
14
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"version": 2,
|
|
3
|
+
"layout": "embedded",
|
|
4
|
+
"gateway": {
|
|
5
|
+
"origin": {
|
|
6
|
+
"mode": "managed",
|
|
7
|
+
"source": ".",
|
|
8
|
+
"runtime": "nodejs",
|
|
9
|
+
"runtimeVersion": "NODE_22_LTS",
|
|
10
|
+
"exclude": ["apitogo/**", "node_modules/**"]
|
|
11
|
+
},
|
|
12
|
+
"specs": ["openapi.yaml"]
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 2,
|
|
3
|
-
"layout": "standalone",
|
|
4
|
-
"gateway": {
|
|
5
|
-
"origin": { "mode": "proxy", "url": "https://api.acme.com" },
|
|
6
|
-
"specs": ["apis/openapi.yaml"],
|
|
7
|
-
"auth": {
|
|
8
|
-
"mode": "oidc",
|
|
9
|
-
"metadataUrl": "https://idp.acme.com/.well-known/openid-configuration",
|
|
10
|
-
"issuers": ["https://idp.acme.com/"],
|
|
11
|
-
"audiences": ["api://acme-orders"]
|
|
12
|
-
},
|
|
13
|
-
"policies": { "rateLimit": { "calls": 60, "periodSeconds": 60 } }
|
|
14
|
-
}
|
|
15
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"version": 2,
|
|
3
|
+
"layout": "standalone",
|
|
4
|
+
"gateway": {
|
|
5
|
+
"origin": { "mode": "proxy", "url": "https://api.acme.com" },
|
|
6
|
+
"specs": ["apis/openapi.yaml"],
|
|
7
|
+
"auth": {
|
|
8
|
+
"mode": "oidc",
|
|
9
|
+
"metadataUrl": "https://idp.acme.com/.well-known/openid-configuration",
|
|
10
|
+
"issuers": ["https://idp.acme.com/"],
|
|
11
|
+
"audiences": ["api://acme-orders"]
|
|
12
|
+
},
|
|
13
|
+
"policies": { "rateLimit": { "calls": 60, "periodSeconds": 60 } }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://apitogo.com/schema/config-v2.json",
|
|
3
|
-
"version": 2,
|
|
4
|
-
"layout": "embedded",
|
|
5
|
-
"project": { "id": "prj_123", "name": "acme-orders" },
|
|
6
|
-
"gateway": {
|
|
7
|
-
"origin": {
|
|
8
|
-
"mode": "managed",
|
|
9
|
-
"source": "server",
|
|
10
|
-
"runtime": "nodejs",
|
|
11
|
-
"runtimeVersion": "NODE_22_LTS"
|
|
12
|
-
},
|
|
13
|
-
"specs": ["server/docs/swagger.yaml"],
|
|
14
|
-
"auth": { "mode": "identity" }
|
|
15
|
-
},
|
|
16
|
-
"identity": { "native": true },
|
|
17
|
-
"plans": {
|
|
18
|
-
"billing": {
|
|
19
|
-
"provider": "stripe",
|
|
20
|
-
"apiKey": "secret:STRIPE_API_KEY",
|
|
21
|
-
"webhookSecret": "secret:STRIPE_WEBHOOK_SECRET"
|
|
22
|
-
}
|
|
23
|
-
},
|
|
24
|
-
"portal": {
|
|
25
|
-
"name": "Acme Orders",
|
|
26
|
-
"docs": { "content": ["docs/**/*.{md,mdx}"] }
|
|
27
|
-
}
|
|
28
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://apitogo.com/schema/config-v2.json",
|
|
3
|
+
"version": 2,
|
|
4
|
+
"layout": "embedded",
|
|
5
|
+
"project": { "id": "prj_123", "name": "acme-orders" },
|
|
6
|
+
"gateway": {
|
|
7
|
+
"origin": {
|
|
8
|
+
"mode": "managed",
|
|
9
|
+
"source": "server",
|
|
10
|
+
"runtime": "nodejs",
|
|
11
|
+
"runtimeVersion": "NODE_22_LTS"
|
|
12
|
+
},
|
|
13
|
+
"specs": ["server/docs/swagger.yaml"],
|
|
14
|
+
"auth": { "mode": "identity" }
|
|
15
|
+
},
|
|
16
|
+
"identity": { "native": true },
|
|
17
|
+
"plans": {
|
|
18
|
+
"billing": {
|
|
19
|
+
"provider": "stripe",
|
|
20
|
+
"apiKey": "secret:STRIPE_API_KEY",
|
|
21
|
+
"webhookSecret": "secret:STRIPE_WEBHOOK_SECRET"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"portal": {
|
|
25
|
+
"name": "Acme Orders",
|
|
26
|
+
"docs": { "content": ["docs/**/*.{md,mdx}"] }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import type { ResolvedConfigV2 } from "./resolve.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Billing (decision 21, narrowed): Stripe is the only payment provider. Billing is on exactly when the plan catalog has a
|
|
6
|
+
* plan with a price, so a project with free and contact-us plans needs no Stripe key and is never asked for one.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The project secret that holds the Stripe key when `plans.billing.apiKey` names none. */
|
|
10
|
+
export const DEFAULT_STRIPE_KEY_SECRET = "STRIPE_API_KEY";
|
|
11
|
+
|
|
12
|
+
type PriceFields = { pricingModel?: unknown; priceAmount?: unknown };
|
|
13
|
+
|
|
14
|
+
/** A plan someone pays for: a fixed price above 0. A contact-us (custom) plan is not paid here. */
|
|
15
|
+
export function isPaidPlan(plan: PriceFields): boolean {
|
|
16
|
+
return (
|
|
17
|
+
plan.pricingModel !== "custom" &&
|
|
18
|
+
typeof plan.priceAmount === "number" &&
|
|
19
|
+
plan.priceAmount > 0
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The skus of the paid plans in the plans file. A missing or unreadable file has none; the plans rules report it. */
|
|
24
|
+
export async function paidPlanSkus(
|
|
25
|
+
resolved: ResolvedConfigV2,
|
|
26
|
+
): Promise<string[]> {
|
|
27
|
+
const file = resolved.plans?.file;
|
|
28
|
+
if (!file) return [];
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(await readFile(file, "utf8")) as {
|
|
31
|
+
plans?: unknown;
|
|
32
|
+
};
|
|
33
|
+
if (!Array.isArray(raw.plans)) return [];
|
|
34
|
+
return raw.plans
|
|
35
|
+
.filter(
|
|
36
|
+
(plan): plan is PriceFields & { sku?: unknown } =>
|
|
37
|
+
typeof plan === "object" && plan !== null && isPaidPlan(plan),
|
|
38
|
+
)
|
|
39
|
+
.map((plan) => String(plan.sku ?? "?"));
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True when the plans service takes payments: Stripe is not switched off and a plan has a price. */
|
|
46
|
+
export async function billingNeeded(
|
|
47
|
+
resolved: ResolvedConfigV2,
|
|
48
|
+
): Promise<boolean> {
|
|
49
|
+
const plans = resolved.plans;
|
|
50
|
+
if (!plans?.enabled || plans.billing.provider !== "stripe") return false;
|
|
51
|
+
return (await paidPlanSkus(resolved)).length > 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The name of the secret that holds the Stripe key (`secret:NAME` in `plans.billing.apiKey`, default `STRIPE_API_KEY`). */
|
|
55
|
+
export function stripeKeySecretName(resolved: ResolvedConfigV2): string {
|
|
56
|
+
const ref = resolved.plans?.billing.apiKey;
|
|
57
|
+
const match = ref ? /^secret:(.+)$/.exec(ref) : null;
|
|
58
|
+
return match?.[1] ?? DEFAULT_STRIPE_KEY_SECRET;
|
|
59
|
+
}
|
|
@@ -1,72 +1,95 @@
|
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
+
/** 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 };
|
|
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() });
|
|
13
|
+
|
|
14
|
+
/** "getting-started" becomes "Getting started". */
|
|
15
|
+
export function folderLabel(name: string): string {
|
|
16
|
+
const words = name.replace(/[-_]+/g, " ").trim();
|
|
17
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const byName = (a: string, b: string) => a.localeCompare(b);
|
|
21
|
+
|
|
22
|
+
const toItems = (branch: Branch, prefix: string): InputNavigationItem[] => {
|
|
23
|
+
const names = [...branch.pages.keys()].sort((a, b) =>
|
|
24
|
+
a === "index" ? -1 : b === "index" ? 1 : byName(a, b),
|
|
25
|
+
);
|
|
26
|
+
const folders = [...branch.folders.keys()].sort(byName);
|
|
27
|
+
const folderPath = prefix === "" ? "/" : `/${prefix.slice(0, -1)}`;
|
|
28
|
+
return [
|
|
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
|
+
}),
|
|
45
|
+
...folders.map(
|
|
46
|
+
(folder): InputNavigationItem => ({
|
|
47
|
+
type: "category",
|
|
48
|
+
label: folderLabel(folder),
|
|
49
|
+
collapsible: true,
|
|
50
|
+
collapsed: false,
|
|
51
|
+
items: toItems(
|
|
52
|
+
branch.folders.get(folder) ?? emptyBranch(),
|
|
53
|
+
`${prefix}${folder}/`,
|
|
54
|
+
),
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
];
|
|
58
|
+
};
|
|
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
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The sidebar for hosted docs, made from the pages themselves: the index page first, then the other pages by
|
|
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.
|
|
75
|
+
* Without pages there is nothing to navigate, so the result is empty.
|
|
76
|
+
*/
|
|
77
|
+
export function docsNavigation(
|
|
78
|
+
routes: string[],
|
|
79
|
+
customPages: readonly CustomDocsPage[] = [],
|
|
80
|
+
): InputNavigationItem[] {
|
|
81
|
+
const root = emptyBranch();
|
|
82
|
+
for (const route of routes) {
|
|
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 });
|
|
90
|
+
}
|
|
91
|
+
const items = toItems(root, "");
|
|
92
|
+
return items.length === 0
|
|
93
|
+
? []
|
|
94
|
+
: [{ type: "category", label: DOCS_NAVIGATION_LABEL, items }];
|
|
95
|
+
}
|