@lukoweb/apitogo 0.2.53 → 0.2.56
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 +15 -2
- package/dist/cli/cli.js +1852 -1235
- package/dist/cli/worker.js +7 -1
- package/dist/declarations/cli/common/spawn-command.d.ts +3 -0
- package/dist/declarations/cli/platform/client/platform-api.d.ts +23 -0
- package/dist/declarations/cli/platform/helpers/platform-plan-catalog.d.ts +6 -0
- package/dist/declarations/cli/platform/helpers/progress.d.ts +4 -1
- package/dist/declarations/lib/components/SiteTitle.d.ts +1 -0
- package/docs/cli/walkthrough.md +1 -1
- package/package.json +1 -1
- package/src/config/v2/docs-navigation.ts +11 -3
- package/src/config/v2/full-server.ts +1 -1
- package/src/config/v2/home-redirect.ts +39 -6
- package/src/config/v2/page-order.ts +73 -0
- package/src/config/v2/services.ts +17 -13
- package/src/config/v2/tsx-pages.ts +10 -1
- package/src/config/v2/wrapper.ts +26 -2
- package/src/lib/authentication/providers/dev-portal-auth-pages.tsx +18 -1
- package/src/lib/components/SiteTitle.tsx +69 -58
- package/src/vite/prerender/worker.ts +132 -118
package/dist/cli/worker.js
CHANGED
|
@@ -115,6 +115,11 @@ var ProtectedRoutesSchema = z.pipe(
|
|
|
115
115
|
|
|
116
116
|
// src/vite/prerender/worker.ts
|
|
117
117
|
var { template, distDir, serverConfigPath, entryServerPath, writeRedirects } = Piscina.workerData;
|
|
118
|
+
var isStaticNavigateWarning = (args) => typeof args[0] === "string" && args[0].includes("<Navigate> must not be used on the initial render");
|
|
119
|
+
var warn = console.warn.bind(console);
|
|
120
|
+
console.warn = (...args) => {
|
|
121
|
+
if (!isStaticNavigateWarning(args)) warn(...args);
|
|
122
|
+
};
|
|
118
123
|
var server = await import(entryServerPath);
|
|
119
124
|
var rawConfig = await import(serverConfigPath).then(
|
|
120
125
|
(m) => m.default
|
|
@@ -186,5 +191,6 @@ var renderPage = async ({ urlPath }) => {
|
|
|
186
191
|
};
|
|
187
192
|
var worker_default = renderPage;
|
|
188
193
|
export {
|
|
189
|
-
worker_default as default
|
|
194
|
+
worker_default as default,
|
|
195
|
+
isStaticNavigateWarning
|
|
190
196
|
};
|
|
@@ -246,6 +246,27 @@ export interface DevPortalEmailDnsRecordDto {
|
|
|
246
246
|
value: string;
|
|
247
247
|
ttlSeconds?: number | null;
|
|
248
248
|
}
|
|
249
|
+
export interface PlatformPlanDto {
|
|
250
|
+
id: string;
|
|
251
|
+
displayName: string;
|
|
252
|
+
tagline: string;
|
|
253
|
+
priceAmount: string;
|
|
254
|
+
pricePeriod: string;
|
|
255
|
+
ctaLabel: string;
|
|
256
|
+
sortOrder: number;
|
|
257
|
+
highlights: string[];
|
|
258
|
+
entitlements: {
|
|
259
|
+
featureKey: string;
|
|
260
|
+
valueType: string;
|
|
261
|
+
value: string;
|
|
262
|
+
}[];
|
|
263
|
+
}
|
|
264
|
+
export interface BillingWebhookStatusDto {
|
|
265
|
+
webhookSecretConfigured: boolean;
|
|
266
|
+
lastEventAtUtc: string | null;
|
|
267
|
+
lastEventType: string | null;
|
|
268
|
+
eventCount: number;
|
|
269
|
+
}
|
|
249
270
|
export interface DevPortalBackendDto {
|
|
250
271
|
fqdn: string | null;
|
|
251
272
|
containerAppName: string;
|
|
@@ -573,6 +594,8 @@ export declare class PlatformApiClient {
|
|
|
573
594
|
cancelUrl?: string;
|
|
574
595
|
}): Promise<DevPortalBillingDto>;
|
|
575
596
|
linkDevPortalIdentity(projectId: string): Promise<DevPortalIdentityLinkDto>;
|
|
597
|
+
listPlatformPlans(): Promise<PlatformPlanDto[]>;
|
|
598
|
+
getBillingWebhookStatus(projectId: string): Promise<BillingWebhookStatusDto | null>;
|
|
576
599
|
getDevPortalBackend(projectId: string): Promise<DevPortalBackendDto | null>;
|
|
577
600
|
getDevPortalSetupStatus(projectId: string): Promise<DevPortalSetupStatusDto>;
|
|
578
601
|
getDevPortalSettings(projectId: string): Promise<DevPortalSettingsDto | null>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PlatformPlanDto } from "../client/platform-api.js";
|
|
1
2
|
import type { FreeProjectsQuota } from "./free-projects-quota.js";
|
|
2
3
|
export type PlatformPlanId = "free" | "starter" | "professional";
|
|
3
4
|
export interface PlatformPlanOption {
|
|
@@ -12,6 +13,11 @@ export interface PlatformPlanOption {
|
|
|
12
13
|
limitations: string[];
|
|
13
14
|
}
|
|
14
15
|
export declare const PLATFORM_PLAN_CATALOG: PlatformPlanOption[];
|
|
16
|
+
export type PlatformPlanDisplay = Pick<PlatformPlanOption, "displayName" | "priceLabel" | "summary" | "features" | "limitations"> & {
|
|
17
|
+
id: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function toPlanDisplays(plans: readonly PlatformPlanDto[]): PlatformPlanDisplay[];
|
|
20
|
+
export declare function loadPlanDisplays(listPlatformPlans: () => Promise<PlatformPlanDto[]>): Promise<PlatformPlanDisplay[]>;
|
|
15
21
|
export declare const PLATFORM_PLAN_USER_PROMPT = "Which API ToGo platform plan do you want for this project \u2014 Free, Starter, or Professional?";
|
|
16
22
|
export declare function formatFreeProjectsQuotaHint(quota: FreeProjectsQuota): string;
|
|
17
23
|
export declare function buildPlatformPlanSelectionAgentInstruction(quota?: FreeProjectsQuota | null): string;
|
|
@@ -42,7 +42,10 @@ export type ConsoleProgressReporter = ProgressReporter & {
|
|
|
42
42
|
stop(): void;
|
|
43
43
|
};
|
|
44
44
|
export declare function friendlyModuleNames(text: string): string;
|
|
45
|
-
export declare function
|
|
45
|
+
export declare function isInfrastructureMessage(message: string): boolean;
|
|
46
|
+
export declare function createConsoleProgressReporter(stream?: NodeJS.WritableStream, options?: {
|
|
47
|
+
verbose?: boolean;
|
|
48
|
+
}): ConsoleProgressReporter;
|
|
46
49
|
export interface PollableJobValue {
|
|
47
50
|
status: string;
|
|
48
51
|
messages?: JobLogMessage[];
|
|
@@ -5,6 +5,7 @@ type SiteTitleProps = {
|
|
|
5
5
|
export declare const splitBrandWordmark: (title: string) => {
|
|
6
6
|
prefix: string;
|
|
7
7
|
accent: string | null;
|
|
8
|
+
spaced: boolean;
|
|
8
9
|
};
|
|
9
10
|
export declare const SiteTitle: ({ title, className }: SiteTitleProps) => import("react/jsx-runtime").JSX.Element;
|
|
10
11
|
export {};
|
package/docs/cli/walkthrough.md
CHANGED
|
@@ -49,7 +49,7 @@ npx apitogo add plans --template free
|
|
|
49
49
|
`--create-spec` writes a small OpenAPI file with one `GET /health` operation, so the gateway has
|
|
50
50
|
something to expose. For a real API, point `gateway.specs` at your own OpenAPI file instead.
|
|
51
51
|
|
|
52
|
-
The Free template in `plans.json` allows
|
|
52
|
+
The Free template in `plans.json` allows 60 calls per minute and 1000 calls per 30 days. Change the
|
|
53
53
|
numbers in `plans.json` and deploy again to change the limits.
|
|
54
54
|
|
|
55
55
|
With plans in the project, customers call the gateway with their API key alone. Their login token is
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { InputNavigationItem } from "../validators/InputNavigationSchema.js";
|
|
2
|
+
import { comparePages, type PageOrders } from "./page-order.js";
|
|
2
3
|
|
|
3
4
|
/** Label of the one header tab that holds the sidebar made from the docs pages. */
|
|
4
5
|
export const DOCS_NAVIGATION_LABEL = "Documentation";
|
|
@@ -19,9 +20,14 @@ export function folderLabel(name: string): string {
|
|
|
19
20
|
|
|
20
21
|
const byName = (a: string, b: string) => a.localeCompare(b);
|
|
21
22
|
|
|
22
|
-
const toItems = (
|
|
23
|
+
const toItems = (
|
|
24
|
+
branch: Branch,
|
|
25
|
+
prefix: string,
|
|
26
|
+
orders: PageOrders,
|
|
27
|
+
): InputNavigationItem[] => {
|
|
28
|
+
// Index first, then the pages' own order, then the usual first pages, then by name (API-125).
|
|
23
29
|
const names = [...branch.pages.keys()].sort((a, b) =>
|
|
24
|
-
a
|
|
30
|
+
comparePages(`/${prefix}${a}`, `/${prefix}${b}`, orders),
|
|
25
31
|
);
|
|
26
32
|
const folders = [...branch.folders.keys()].sort(byName);
|
|
27
33
|
const folderPath = prefix === "" ? "/" : `/${prefix.slice(0, -1)}`;
|
|
@@ -51,6 +57,7 @@ const toItems = (branch: Branch, prefix: string): InputNavigationItem[] => {
|
|
|
51
57
|
items: toItems(
|
|
52
58
|
branch.folders.get(folder) ?? emptyBranch(),
|
|
53
59
|
`${prefix}${folder}/`,
|
|
60
|
+
orders,
|
|
54
61
|
),
|
|
55
62
|
}),
|
|
56
63
|
),
|
|
@@ -77,6 +84,7 @@ const branchOf = (root: Branch, route: string) => {
|
|
|
77
84
|
export function docsNavigation(
|
|
78
85
|
routes: string[],
|
|
79
86
|
customPages: readonly CustomDocsPage[] = [],
|
|
87
|
+
orders: PageOrders = new Map(),
|
|
80
88
|
): InputNavigationItem[] {
|
|
81
89
|
const root = emptyBranch();
|
|
82
90
|
for (const route of routes) {
|
|
@@ -88,7 +96,7 @@ export function docsNavigation(
|
|
|
88
96
|
if (page && !branch.pages.has(page))
|
|
89
97
|
branch.pages.set(page, { kind: "custom", ...custom });
|
|
90
98
|
}
|
|
91
|
-
const items = toItems(root, "");
|
|
99
|
+
const items = toItems(root, "", orders);
|
|
92
100
|
return items.length === 0
|
|
93
101
|
? []
|
|
94
102
|
: [{ type: "category", label: DOCS_NAVIGATION_LABEL, items }];
|
|
@@ -49,7 +49,7 @@ const items = [
|
|
|
49
49
|
{ id: "3", name: "Third item" },
|
|
50
50
|
];
|
|
51
51
|
|
|
52
|
-
// Lets the portal's API page call this server while you run it locally (\`apitogo
|
|
52
|
+
// Lets the portal's API page call this server while you run it locally (\`apitogo run\`). Once deployed, the gateway
|
|
53
53
|
// answers browsers itself and keeps its own list of allowed sites.
|
|
54
54
|
const cors = {
|
|
55
55
|
"access-control-allow-origin": "*",
|
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { glob } from "glob";
|
|
3
3
|
import globParent from "glob-parent";
|
|
4
4
|
import { readFrontmatter } from "../../lib/util/readFrontmatter.js";
|
|
5
|
+
import { firstPage, orderValue, type PageOrders } from "./page-order.js";
|
|
5
6
|
import { toGlobPath } from "./resolve.js";
|
|
6
7
|
|
|
7
8
|
export type HomeRedirect = { from: string; to: string };
|
|
@@ -57,11 +58,41 @@ export async function docRoutes(
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** The page a visitor lands on when the site has no home page: the top-most page, then the first by name. */
|
|
60
|
-
export function pickLandingRoute(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
)
|
|
61
|
+
export function pickLandingRoute(
|
|
62
|
+
routes: string[],
|
|
63
|
+
orders?: PageOrders,
|
|
64
|
+
): string | undefined {
|
|
65
|
+
return firstPage(routes, orders);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The `order` each Markdown page sets in its frontmatter, by route (API-125). Pages without one are not in the map.
|
|
70
|
+
*/
|
|
71
|
+
export async function docPageOrders(
|
|
72
|
+
patterns: string[],
|
|
73
|
+
): Promise<Map<string, number>> {
|
|
74
|
+
const globs = patterns.map(toGlobPath);
|
|
75
|
+
const excluded = globs
|
|
76
|
+
.filter((pattern) => pattern.startsWith("!"))
|
|
77
|
+
.map((pattern) => pattern.slice(1));
|
|
78
|
+
const orders = new Map<string, number>();
|
|
79
|
+
for (const pattern of globs.filter((p) => !p.startsWith("!"))) {
|
|
80
|
+
const parent = globParent(pattern);
|
|
81
|
+
const files = await glob(pattern, {
|
|
82
|
+
absolute: true,
|
|
83
|
+
posix: true,
|
|
84
|
+
ignore: [...IGNORED, ...excluded],
|
|
85
|
+
});
|
|
86
|
+
for (const raw of files) {
|
|
87
|
+
const order = orderValue((await readFrontmatter(raw)).data.order);
|
|
88
|
+
if (order === undefined) continue;
|
|
89
|
+
const route = `/${path.posix
|
|
90
|
+
.relative(parent, raw.replace(/^\/\/\?\//, ""))
|
|
91
|
+
.replace(/\.mdx?$/, "")}`;
|
|
92
|
+
if (!orders.has(route)) orders.set(route, order);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return orders;
|
|
65
96
|
}
|
|
66
97
|
|
|
67
98
|
export type HomeRedirectInput = {
|
|
@@ -77,6 +108,8 @@ export type HomeRedirectInput = {
|
|
|
77
108
|
userPanel: boolean;
|
|
78
109
|
/** The portal has a pricing page, at this path. */
|
|
79
110
|
pricingPath?: string;
|
|
111
|
+
/** The pages' own order (API-125), so `/` opens the first page in the sidebar's order. */
|
|
112
|
+
pageOrders?: PageOrders;
|
|
80
113
|
};
|
|
81
114
|
|
|
82
115
|
/**
|
|
@@ -96,7 +129,7 @@ export function homeRedirect(
|
|
|
96
129
|
if (input.hostsDocs) {
|
|
97
130
|
if (input.docRoutes.some((route) => route === "/" || route === "/index"))
|
|
98
131
|
return undefined;
|
|
99
|
-
const landing = pickLandingRoute(input.docRoutes);
|
|
132
|
+
const landing = pickLandingRoute(input.docRoutes, input.pageOrders);
|
|
100
133
|
if (landing) return { from: "/", to: landing };
|
|
101
134
|
return input.apiReferencePath
|
|
102
135
|
? { from: "/", to: input.apiReferencePath }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The order of docs pages (API-125). A page may set it (`order: 2` in the frontmatter of a Markdown page,
|
|
3
|
+
* `export const order = 2` in a React page). Pages with an order come first, by that number; the others follow, the
|
|
4
|
+
* usual first pages (introduction, getting started, ...) before the rest, then by name. A folder's index page is
|
|
5
|
+
* always its first page. Before this the order was by name only, so "Changelog" came first and the header's
|
|
6
|
+
* "Documentation" link opened it.
|
|
7
|
+
*/
|
|
8
|
+
export type PageOrders = ReadonlyMap<string, number>;
|
|
9
|
+
|
|
10
|
+
/** Names that usually open a set of docs, best first. */
|
|
11
|
+
const FIRST_PAGE_NAMES = [
|
|
12
|
+
"introduction",
|
|
13
|
+
"intro",
|
|
14
|
+
"welcome",
|
|
15
|
+
"getting-started",
|
|
16
|
+
"get-started",
|
|
17
|
+
"overview",
|
|
18
|
+
"quickstart",
|
|
19
|
+
"quick-start",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const lastSegment = (route: string) =>
|
|
23
|
+
route.split("/").filter(Boolean).pop() ?? "";
|
|
24
|
+
|
|
25
|
+
const rank = (route: string, orders: PageOrders) => {
|
|
26
|
+
const name = lastSegment(route).toLowerCase();
|
|
27
|
+
if (name === "index" || route === "/") return [0, 0] as const;
|
|
28
|
+
const order = orders.get(route);
|
|
29
|
+
if (order !== undefined) return [1, order] as const;
|
|
30
|
+
const preferred = FIRST_PAGE_NAMES.indexOf(name);
|
|
31
|
+
return [2, preferred === -1 ? FIRST_PAGE_NAMES.length : preferred] as const;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Sorts sibling pages: index, then by `order`, then the usual first pages, then by name. */
|
|
35
|
+
export function comparePages(
|
|
36
|
+
a: string,
|
|
37
|
+
b: string,
|
|
38
|
+
orders: PageOrders = new Map(),
|
|
39
|
+
): number {
|
|
40
|
+
const [groupA, valueA] = rank(a, orders);
|
|
41
|
+
const [groupB, valueB] = rank(b, orders);
|
|
42
|
+
return groupA - groupB || valueA - valueB || a.localeCompare(b);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The page a visitor lands on: the top-most page, then the first in page order. */
|
|
46
|
+
export function firstPage(
|
|
47
|
+
routes: readonly string[],
|
|
48
|
+
orders: PageOrders = new Map(),
|
|
49
|
+
): string | undefined {
|
|
50
|
+
const depth = (route: string) => route.split("/").length;
|
|
51
|
+
return [...routes].sort(
|
|
52
|
+
(a, b) => depth(a) - depth(b) || comparePages(a, b, orders),
|
|
53
|
+
)[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `order: 3` (or `order: "3"`) from a page's frontmatter; undefined when it is missing or not a whole number. */
|
|
57
|
+
export function orderValue(value: unknown): number | undefined {
|
|
58
|
+
const number =
|
|
59
|
+
typeof value === "number"
|
|
60
|
+
? value
|
|
61
|
+
: typeof value === "string" && value.trim() !== ""
|
|
62
|
+
? Number(value)
|
|
63
|
+
: Number.NaN;
|
|
64
|
+
return Number.isInteger(number) ? number : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TSX_ORDER = /export\s+const\s+order\s*(?::\s*number\s*)?=\s*(-?\d+)/;
|
|
68
|
+
|
|
69
|
+
/** `export const order = 3` in a React page. */
|
|
70
|
+
export function tsxPageOrder(source: string): number | undefined {
|
|
71
|
+
const match = TSX_ORDER.exec(source);
|
|
72
|
+
return match?.[1] ? Number(match[1]) : undefined;
|
|
73
|
+
}
|
|
@@ -249,21 +249,23 @@ const PLAN_TEMPLATES: Record<
|
|
|
249
249
|
displayName: "Free",
|
|
250
250
|
default: true,
|
|
251
251
|
limits: {
|
|
252
|
-
// The platform's Free tier allows at most
|
|
253
|
-
rateLimit: { calls:
|
|
252
|
+
// The platform's Free tier allows at most 60 calls per minute per plan (its rate_limit_per_minute ceiling).
|
|
253
|
+
rateLimit: { calls: 60, periodSeconds: 60 },
|
|
254
254
|
quota: { calls: 1000, periodDays: 30 },
|
|
255
255
|
},
|
|
256
256
|
},
|
|
257
257
|
],
|
|
258
|
-
// Sample plans for the
|
|
259
|
-
//
|
|
258
|
+
// Sample plans for the full template: a free plan and one paid example (API-124). The example's price is in the file
|
|
259
|
+
// but it is a contact-us plan, so the first deploy works on a Free project (a plan with a price needs Starter or
|
|
260
|
+
// Professional and billing). `apitogo plan` names the command that starts selling it. It has no limits of its own,
|
|
261
|
+
// so it stays within any project's ceiling until the tenant sets them.
|
|
260
262
|
starter: [
|
|
261
263
|
{
|
|
262
264
|
sku: "free",
|
|
263
265
|
displayName: "Free",
|
|
264
266
|
default: true,
|
|
265
267
|
limits: {
|
|
266
|
-
rateLimit: { calls:
|
|
268
|
+
rateLimit: { calls: 60, periodSeconds: 60 },
|
|
267
269
|
quota: { calls: 1000, periodDays: 30 },
|
|
268
270
|
},
|
|
269
271
|
},
|
|
@@ -272,12 +274,9 @@ const PLAN_TEMPLATES: Record<
|
|
|
272
274
|
displayName: "Pro",
|
|
273
275
|
pricingModel: "custom",
|
|
274
276
|
priceLabel: "Contact us",
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
displayName: "Enterprise",
|
|
279
|
-
pricingModel: "custom",
|
|
280
|
-
priceLabel: "Contact us",
|
|
277
|
+
priceAmount: 19,
|
|
278
|
+
priceCurrency: "usd",
|
|
279
|
+
billingInterval: "month",
|
|
281
280
|
},
|
|
282
281
|
],
|
|
283
282
|
tiered: [
|
|
@@ -286,8 +285,8 @@ const PLAN_TEMPLATES: Record<
|
|
|
286
285
|
displayName: "Free",
|
|
287
286
|
default: true,
|
|
288
287
|
limits: {
|
|
289
|
-
// The platform's Free tier allows at most
|
|
290
|
-
rateLimit: { calls:
|
|
288
|
+
// The platform's Free tier allows at most 60 calls per minute per plan (its rate_limit_per_minute ceiling).
|
|
289
|
+
rateLimit: { calls: 60, periodSeconds: 60 },
|
|
291
290
|
quota: { calls: 1000, periodDays: 30 },
|
|
292
291
|
},
|
|
293
292
|
},
|
|
@@ -686,6 +685,11 @@ async function buildGateway(
|
|
|
686
685
|
: {}),
|
|
687
686
|
...(quota ? { quota: { calls: quota[0], periodDays: quota[1] } } : {}),
|
|
688
687
|
...(o.cors?.length ? { cors: o.cors } : {}),
|
|
688
|
+
// The sample server's health check needs no key: monitors and the platform call it, and it is not something a
|
|
689
|
+
// plan sells, so it stays out of the plans' feature lists (API-126).
|
|
690
|
+
...(sampleServer
|
|
691
|
+
? { operations: { public: [`GET ${FULL_SERVER_HEALTH_PATH}`] } }
|
|
692
|
+
: {}),
|
|
689
693
|
};
|
|
690
694
|
|
|
691
695
|
return {
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { glob } from "glob";
|
|
4
4
|
import globParent from "glob-parent";
|
|
5
5
|
import { folderLabel } from "./docs-navigation.js";
|
|
6
|
+
import { tsxPageOrder } from "./page-order.js";
|
|
6
7
|
import { toGlobPath } from "./resolve.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -18,6 +19,8 @@ export type TsxPage = {
|
|
|
18
19
|
file: string;
|
|
19
20
|
/** Sidebar label: `export const title = "..."` in the file, or the file name. */
|
|
20
21
|
label: string;
|
|
22
|
+
/** `export const order = 2` in the file (API-125). */
|
|
23
|
+
order?: number;
|
|
21
24
|
};
|
|
22
25
|
|
|
23
26
|
const IGNORED = ["**/node_modules/**", "**/dist/**", "**/.git/**"];
|
|
@@ -90,7 +93,13 @@ export async function findTsxPages(
|
|
|
90
93
|
const route = `/${relative.replace(/\.tsx$/, "")}`;
|
|
91
94
|
if (found.has(route)) continue;
|
|
92
95
|
const source = await readFile(file, "utf8").catch(() => "");
|
|
93
|
-
|
|
96
|
+
const order = tsxPageOrder(source);
|
|
97
|
+
found.set(route, {
|
|
98
|
+
route,
|
|
99
|
+
file,
|
|
100
|
+
label: tsxPageLabel(source, file),
|
|
101
|
+
...(order !== undefined ? { order } : {}),
|
|
102
|
+
});
|
|
94
103
|
}
|
|
95
104
|
}
|
|
96
105
|
return [...found.values()].sort((a, b) => a.route.localeCompare(b.route));
|
package/src/config/v2/wrapper.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
docRoutes,
|
|
11
11
|
type HomeRedirect,
|
|
12
12
|
homeRedirect,
|
|
13
|
+
docPageOrders,
|
|
13
14
|
pickLandingRoute,
|
|
14
15
|
} from "./home-redirect.js";
|
|
15
16
|
import {
|
|
@@ -93,6 +94,8 @@ export type PortalPlan = {
|
|
|
93
94
|
keyHeader?: string;
|
|
94
95
|
/** The gateway's address, for code samples on the portal's pages. */
|
|
95
96
|
gatewayBaseUrl?: string;
|
|
97
|
+
/** The plan shown as "Popular": only the one the tenant marked `featured` in plans.json (API-124). */
|
|
98
|
+
featuredPlanSku?: string;
|
|
96
99
|
};
|
|
97
100
|
/** Account pages. */
|
|
98
101
|
userPanel: boolean;
|
|
@@ -677,13 +680,32 @@ export async function preparePortal(
|
|
|
677
680
|
const hostedDocRoutes = [
|
|
678
681
|
...new Set([...markdownRoutes, ...tsxPages.map((page) => page.route)]),
|
|
679
682
|
].sort();
|
|
683
|
+
// The pages' own order (API-125): the sidebar, `/` and the "Documentation" link follow it.
|
|
684
|
+
const pageOrders = plan.docs
|
|
685
|
+
? await docPageOrders(profile.docs.content)
|
|
686
|
+
: new Map<string, number>();
|
|
687
|
+
for (const page of tsxPages)
|
|
688
|
+
if (page.order !== undefined && !pageOrders.has(page.route))
|
|
689
|
+
pageOrders.set(page.route, page.order);
|
|
690
|
+
// "Popular" is the tenant's choice, never guessed (API-124).
|
|
691
|
+
if (plan.plansPlugin) {
|
|
692
|
+
const featured = (await readDemoPlans(resolved.plans?.file))?.find(
|
|
693
|
+
(item): item is { sku: string } =>
|
|
694
|
+
typeof item === "object" &&
|
|
695
|
+
item !== null &&
|
|
696
|
+
(item as { featured?: unknown }).featured === true &&
|
|
697
|
+
typeof (item as { sku?: unknown }).sku === "string",
|
|
698
|
+
);
|
|
699
|
+
if (featured)
|
|
700
|
+
plan.plansPlugin.featuredPlanSku = featured.sku.trim().toLowerCase();
|
|
701
|
+
}
|
|
680
702
|
// The account pages' "Documentation" link goes to a page the portal really has, or is left out.
|
|
681
703
|
if (plan.plansPlugin && plan.docs) {
|
|
682
704
|
const landing = hostedDocRoutes.some(
|
|
683
705
|
(route) => route === "/" || route === "/index",
|
|
684
706
|
)
|
|
685
707
|
? "/"
|
|
686
|
-
: pickLandingRoute(hostedDocRoutes);
|
|
708
|
+
: pickLandingRoute(hostedDocRoutes, pageOrders);
|
|
687
709
|
if (landing)
|
|
688
710
|
plan.plansPlugin.links = { ...plan.plansPlugin.links, docs: landing };
|
|
689
711
|
}
|
|
@@ -702,6 +724,7 @@ export async function preparePortal(
|
|
|
702
724
|
plan.plansPlugin && hasCapability(effective.services.plans, "catalog")
|
|
703
725
|
? CONSOLE_ROUTES.pricing
|
|
704
726
|
: undefined,
|
|
727
|
+
pageOrders,
|
|
705
728
|
});
|
|
706
729
|
const siteConfig = buildSiteConfig(
|
|
707
730
|
resolved,
|
|
@@ -716,8 +739,9 @@ export async function preparePortal(
|
|
|
716
739
|
label: page.label,
|
|
717
740
|
element: wrapperPages[index]?.id ?? "",
|
|
718
741
|
})),
|
|
742
|
+
pageOrders,
|
|
719
743
|
),
|
|
720
|
-
docsPath: pickLandingRoute(hostedDocRoutes),
|
|
744
|
+
docsPath: pickLandingRoute(hostedDocRoutes, pageOrders),
|
|
721
745
|
demoPlans:
|
|
722
746
|
plan.demo && plan.plansPlugin
|
|
723
747
|
? await readDemoPlans(resolved.plans?.file)
|
|
@@ -358,6 +358,19 @@ export function DevPortalRegisterPage({
|
|
|
358
358
|
.finally(() => setConfigLoading(false));
|
|
359
359
|
}, [baseUrl]);
|
|
360
360
|
|
|
361
|
+
// With one sign-in provider and no native sign-up, the button was a needless extra step: the page goes to the
|
|
362
|
+
// provider at once, and the button stays as a fallback (API-126).
|
|
363
|
+
const onlyProvider =
|
|
364
|
+
config && !isNativeAuthEnabled(config) && config.providers.length === 1
|
|
365
|
+
? config.providers[0]
|
|
366
|
+
: undefined;
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
if (!baseUrl || !onlyProvider) return;
|
|
369
|
+
window.location.assign(
|
|
370
|
+
buildDevPortalOidcChallengeUrl(baseUrl, onlyProvider.id, returnUrl),
|
|
371
|
+
);
|
|
372
|
+
}, [baseUrl, onlyProvider, returnUrl]);
|
|
373
|
+
|
|
361
374
|
const onSubmit = useCallback(
|
|
362
375
|
async (event: React.FormEvent) => {
|
|
363
376
|
event.preventDefault();
|
|
@@ -408,7 +421,11 @@ export function DevPortalRegisterPage({
|
|
|
408
421
|
return (
|
|
409
422
|
<AuthPageShell
|
|
410
423
|
title="Create your account"
|
|
411
|
-
description=
|
|
424
|
+
description={
|
|
425
|
+
onlyProvider
|
|
426
|
+
? "Opening the sign-up form…"
|
|
427
|
+
: "Accounts are created on our secure sign-in provider."
|
|
428
|
+
}
|
|
412
429
|
>
|
|
413
430
|
{config.providers.length ? (
|
|
414
431
|
<OidcProviderButtons
|
|
@@ -1,58 +1,69 @@
|
|
|
1
|
-
import { cn } from "../util/cn.js";
|
|
2
|
-
|
|
3
|
-
type SiteTitleProps = {
|
|
4
|
-
title: string;
|
|
5
|
-
className?: string;
|
|
6
|
-
};
|
|
7
|
-
|
|
8
|
-
export const splitBrandWordmark = (title: string) => {
|
|
9
|
-
const trimmed = title.trim();
|
|
10
|
-
if (!trimmed) {
|
|
11
|
-
return { prefix: title, accent: null as string | null };
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
1
|
+
import { cn } from "../util/cn.js";
|
|
2
|
+
|
|
3
|
+
type SiteTitleProps = {
|
|
4
|
+
title: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const splitBrandWordmark = (title: string) => {
|
|
9
|
+
const trimmed = title.trim();
|
|
10
|
+
if (!trimmed) {
|
|
11
|
+
return { prefix: title, accent: null as string | null, spaced: false };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A name of several words keeps its space before the accent: "full portal", not "fullportal" (API-126).
|
|
15
|
+
const words = trimmed.split(/\s+/).filter(Boolean);
|
|
16
|
+
if (words.length >= 2) {
|
|
17
|
+
return {
|
|
18
|
+
prefix: words.slice(0, -1).join(" "),
|
|
19
|
+
accent: words.at(-1) ?? null,
|
|
20
|
+
spaced: true,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const word = words[0] ?? trimmed;
|
|
25
|
+
const apiMatch = word.match(/^(.*)(API)$/i);
|
|
26
|
+
if (apiMatch?.[1]) {
|
|
27
|
+
return { prefix: apiMatch[1], accent: apiMatch[2] ?? "API", spaced: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const camelMatch = word.match(/^(.*[a-z])([A-Z][a-z]*)$/);
|
|
31
|
+
if (camelMatch?.[1]) {
|
|
32
|
+
return {
|
|
33
|
+
prefix: camelMatch[1],
|
|
34
|
+
accent: camelMatch[2] ?? null,
|
|
35
|
+
spaced: false,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (word.length < 2) {
|
|
40
|
+
return { prefix: word, accent: null, spaced: false };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const splitAt = Math.floor(word.length / 2);
|
|
44
|
+
return {
|
|
45
|
+
prefix: word.slice(0, splitAt),
|
|
46
|
+
accent: word.slice(splitAt),
|
|
47
|
+
spaced: false,
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const SiteTitle = ({ title, className }: SiteTitleProps) => {
|
|
52
|
+
const { prefix, accent, spaced } = splitBrandWordmark(title);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<span
|
|
56
|
+
className={cn(
|
|
57
|
+
"font-sans text-[1.4rem] font-extrabold tracking-[-0.03em]",
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
style={{ color: "var(--foreground)" }}
|
|
61
|
+
>
|
|
62
|
+
{prefix}
|
|
63
|
+
{accent && spaced ? " " : null}
|
|
64
|
+
{accent ? (
|
|
65
|
+
<span style={{ color: "var(--primary)" }}>{accent}</span>
|
|
66
|
+
) : null}
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
};
|