@lukoweb/apitogo 0.2.54 → 0.2.57
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 +2196 -1352
- package/dist/declarations/cli/platform/client/platform-api.d.ts +29 -0
- package/dist/declarations/cli/platform/helpers/platform-plan-catalog.d.ts +6 -0
- package/dist/declarations/config/validators/ZudokuConfig.d.ts +2 -0
- package/dist/declarations/lib/authentication/providers/openid.d.ts +2 -1
- package/dist/declarations/lib/plugins/markdown/index.d.ts +2 -1
- package/dist/flat-config.d.ts +1 -0
- package/docs/cli/commands.md +50 -32
- package/docs/cli/go-live.md +66 -93
- package/docs/cli/overview.md +18 -17
- package/docs/cli/setup.md +130 -141
- package/docs/cli/shape.md +94 -11
- package/docs/cli/walkthrough.md +1 -1
- package/docs/components/slot.mdx +116 -119
- package/docs/configuration/api-reference.md +98 -397
- package/docs/configuration/authentication.md +118 -159
- package/docs/configuration/footer.mdx +89 -118
- package/docs/configuration/make-config.mdx +2 -2
- package/docs/configuration/overview.md +97 -166
- package/docs/configuration/plans.md +100 -110
- package/docs/configuration/portal.md +116 -0
- package/docs/customization/colors-theme.mdx +255 -278
- package/docs/customization/fonts.md +105 -110
- package/docs/customization/landing-page.md +189 -188
- package/docs/deploy/apitogo.md +13 -17
- package/docs/deployment.md +15 -9
- package/docs/guides/stripe-setup.mdx +123 -0
- package/docs/markdown/frontmatter.md +172 -173
- package/docs/mcp/go-live.md +100 -100
- package/docs/mcp/overview.md +76 -76
- package/docs/quickstart.md +88 -72
- package/docs/writing.mdx +68 -72
- package/package.json +1 -1
- package/src/config/v2/docs-navigation.ts +7 -2
- package/src/config/v2/index.ts +1 -0
- package/src/config/v2/resolve.ts +28 -0
- package/src/config/v2/rules.ts +32 -1
- package/src/config/v2/schema.ts +9 -0
- package/src/config/v2/services.ts +90 -15
- package/src/config/v2/starter-landing.ts +41 -0
- package/src/config/v2/wrapper.ts +141 -7
- package/src/config/validators/ZudokuConfig.ts +794 -792
- package/src/lib/authentication/providers/openid.tsx +6 -1
- package/src/lib/plugins/markdown/index.tsx +86 -82
- package/src/vite/dev-server.ts +9 -1
- package/src/vite/dev-ssr-config.ts +32 -0
- package/src/vite/plugin-config.ts +4 -2
- package/src/vite/plugin-docs.ts +213 -205
- package/docs/concepts/auth-provider-api-identities.md +0 -105
- package/docs/configuration/ai-assistants.md +0 -65
- package/docs/configuration/api-catalog.md +0 -109
- package/docs/configuration/authentication-auth0.md +0 -181
- package/docs/configuration/authentication-azure-ad.md +0 -245
- package/docs/configuration/authentication-clerk.md +0 -117
- package/docs/configuration/authentication-firebase.md +0 -69
- package/docs/configuration/authentication-pingfederate.md +0 -143
- package/docs/configuration/authentication-supabase.md +0 -233
- package/docs/configuration/build-configuration.mdx +0 -147
- package/docs/configuration/docs.md +0 -282
- package/docs/configuration/llms.md +0 -90
- package/docs/configuration/manifest.md +0 -129
- package/docs/configuration/navigation.mdx +0 -415
- package/docs/configuration/protected-routes.md +0 -150
- package/docs/configuration/search.md +0 -164
- package/docs/configuration/sentry.mdx +0 -44
- package/docs/configuration/site.md +0 -124
- package/docs/configuration/slots.mdx +0 -125
- package/docs/configuration/vite-config.md +0 -18
- package/docs/custom-plugins.md +0 -285
- package/docs/deploy/apache-nginx.md +0 -41
- package/docs/deploy/cloudflare-pages.md +0 -74
- package/docs/deploy/direct-upload.md +0 -17
- package/docs/deploy/github-pages.md +0 -49
- package/docs/deploy/vercel.md +0 -101
- package/docs/extending/events.md +0 -124
- package/docs/guides/environment-variables.md +0 -94
- package/docs/guides/managing-api-keys-and-identities.md +0 -89
- package/docs/guides/navigation-rules.mdx +0 -203
- package/docs/guides/processors.mdx +0 -234
- package/docs/guides/transforming-examples.md +0 -156
- package/docs/guides/using-multiple-apis.md +0 -87
- package/docs/plugins.md +0 -24
package/package.json
CHANGED
|
@@ -24,13 +24,16 @@ const toItems = (
|
|
|
24
24
|
branch: Branch,
|
|
25
25
|
prefix: string,
|
|
26
26
|
orders: PageOrders,
|
|
27
|
+
homeTaken = false,
|
|
27
28
|
): InputNavigationItem[] => {
|
|
28
29
|
// Index first, then the pages' own order, then the usual first pages, then by name (API-125).
|
|
29
30
|
const names = [...branch.pages.keys()].sort((a, b) =>
|
|
30
31
|
comparePages(`/${prefix}${a}`, `/${prefix}${b}`, orders),
|
|
31
32
|
);
|
|
32
33
|
const folders = [...branch.folders.keys()].sort(byName);
|
|
33
|
-
|
|
34
|
+
// With a home page of its own, `/` is the home page and the root index page keeps `/index` (API-129).
|
|
35
|
+
const folderPath =
|
|
36
|
+
prefix === "" ? (homeTaken ? "/index" : "/") : `/${prefix.slice(0, -1)}`;
|
|
34
37
|
return [
|
|
35
38
|
...names.map((name): InputNavigationItem => {
|
|
36
39
|
const page = branch.pages.get(name) as Page;
|
|
@@ -58,6 +61,7 @@ const toItems = (
|
|
|
58
61
|
branch.folders.get(folder) ?? emptyBranch(),
|
|
59
62
|
`${prefix}${folder}/`,
|
|
60
63
|
orders,
|
|
64
|
+
homeTaken,
|
|
61
65
|
),
|
|
62
66
|
}),
|
|
63
67
|
),
|
|
@@ -85,6 +89,7 @@ export function docsNavigation(
|
|
|
85
89
|
routes: string[],
|
|
86
90
|
customPages: readonly CustomDocsPage[] = [],
|
|
87
91
|
orders: PageOrders = new Map(),
|
|
92
|
+
options: { homeTaken?: boolean } = {},
|
|
88
93
|
): InputNavigationItem[] {
|
|
89
94
|
const root = emptyBranch();
|
|
90
95
|
for (const route of routes) {
|
|
@@ -96,7 +101,7 @@ export function docsNavigation(
|
|
|
96
101
|
if (page && !branch.pages.has(page))
|
|
97
102
|
branch.pages.set(page, { kind: "custom", ...custom });
|
|
98
103
|
}
|
|
99
|
-
const items = toItems(root, "", orders);
|
|
104
|
+
const items = toItems(root, "", orders, options.homeTaken);
|
|
100
105
|
return items.length === 0
|
|
101
106
|
? []
|
|
102
107
|
: [{ type: "category", label: DOCS_NAVIGATION_LABEL, items }];
|
package/src/config/v2/index.ts
CHANGED
package/src/config/v2/resolve.ts
CHANGED
|
@@ -34,6 +34,10 @@ export type ResolvedPortal = {
|
|
|
34
34
|
theme: string;
|
|
35
35
|
/** Absolute path of the custom home page component, when the portal has one. */
|
|
36
36
|
landing?: string;
|
|
37
|
+
/** Absolute path of the footer component, when the portal has one (API-129). */
|
|
38
|
+
footer?: string;
|
|
39
|
+
/** The header logo, when the portal has one: absolute paths (inside the portal's `public/`) or https URLs (API-129). */
|
|
40
|
+
logo?: { light: string; dark: string; alt?: string };
|
|
37
41
|
requireAuth: boolean | { exclude: string[] };
|
|
38
42
|
search: "pagefind" | "inkeep" | "none";
|
|
39
43
|
};
|
|
@@ -92,6 +96,12 @@ export type ResolvedConfigV2 = {
|
|
|
92
96
|
plans?: ResolvedPlans;
|
|
93
97
|
};
|
|
94
98
|
|
|
99
|
+
/** A logo is a file of the portal (served from its `public/` folder) or an https address, which is kept as it is. */
|
|
100
|
+
export const isLogoUrl = (value: string) =>
|
|
101
|
+
value.toLowerCase().startsWith("https://");
|
|
102
|
+
const logoSource = (configDir: string, value: string) =>
|
|
103
|
+
isLogoUrl(value) ? value : path.resolve(configDir, value);
|
|
104
|
+
|
|
95
105
|
/**
|
|
96
106
|
* Applies the documented defaults and makes every path absolute. Defaults for `portal.dir`, `plans.file`
|
|
97
107
|
* and the spec folder derive from `<root>`, so a standalone config can omit them. A section that is present
|
|
@@ -146,6 +156,24 @@ export function resolveConfigV2(
|
|
|
146
156
|
...(portal.landing
|
|
147
157
|
? { landing: path.resolve(configDir, portal.landing) }
|
|
148
158
|
: {}),
|
|
159
|
+
...(portal.footer
|
|
160
|
+
? { footer: path.resolve(configDir, portal.footer) }
|
|
161
|
+
: {}),
|
|
162
|
+
...(portal.logo
|
|
163
|
+
? {
|
|
164
|
+
logo:
|
|
165
|
+
typeof portal.logo === "string"
|
|
166
|
+
? {
|
|
167
|
+
light: logoSource(configDir, portal.logo),
|
|
168
|
+
dark: logoSource(configDir, portal.logo),
|
|
169
|
+
}
|
|
170
|
+
: {
|
|
171
|
+
light: logoSource(configDir, portal.logo.light),
|
|
172
|
+
dark: logoSource(configDir, portal.logo.dark),
|
|
173
|
+
...(portal.logo.alt ? { alt: portal.logo.alt } : {}),
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
: {}),
|
|
149
177
|
requireAuth: portal.requireAuth ?? false,
|
|
150
178
|
search: provider === "external" ? "none" : (portal.search ?? "pagefind"),
|
|
151
179
|
};
|
package/src/config/v2/rules.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { glob } from "glob";
|
|
|
4
4
|
import { parse as parseYaml } from "yaml";
|
|
5
5
|
import { billingNeeded, stripeKeySecretName } from "./billing.js";
|
|
6
6
|
import type { ConfigIssue } from "./errors.js";
|
|
7
|
-
import { type ResolvedConfigV2, toGlobPath } from "./resolve.js";
|
|
7
|
+
import { isLogoUrl, type ResolvedConfigV2, toGlobPath } from "./resolve.js";
|
|
8
8
|
import type { ConfigV2 } from "./schema.js";
|
|
9
9
|
import { findTsxPages } from "./tsx-pages.js";
|
|
10
10
|
|
|
@@ -247,6 +247,37 @@ async function portalRules(
|
|
|
247
247
|
});
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
// A missing footer or logo file would fail the build, so check says so first (API-129).
|
|
251
|
+
if (portal.footer && !(await isFile(portal.footer)))
|
|
252
|
+
out.error({
|
|
253
|
+
code: "PORTAL_FILE_MISSING",
|
|
254
|
+
path: "portal.footer",
|
|
255
|
+
message: `the file ${path.relative(resolved.configDir, portal.footer)} does not exist.`,
|
|
256
|
+
hint: "Create it, or remove portal.footer.",
|
|
257
|
+
});
|
|
258
|
+
const publicDir = path.join(portal.dir, "public");
|
|
259
|
+
for (const file of new Set(
|
|
260
|
+
[portal.logo?.light, portal.logo?.dark].filter(
|
|
261
|
+
(value): value is string => !!value && !isLogoUrl(value),
|
|
262
|
+
),
|
|
263
|
+
)) {
|
|
264
|
+
const inside = path.relative(publicDir, file);
|
|
265
|
+
if (inside.startsWith("..") || path.isAbsolute(inside))
|
|
266
|
+
out.error({
|
|
267
|
+
code: "PORTAL_LOGO_OUTSIDE_PUBLIC",
|
|
268
|
+
path: "portal.logo",
|
|
269
|
+
message: `the logo ${path.relative(resolved.configDir, file)} is not in the portal's public folder, which is what the site serves.`,
|
|
270
|
+
hint: `Move it into ${path.relative(resolved.configDir, publicDir)}/, or use an https:// address.`,
|
|
271
|
+
});
|
|
272
|
+
else if (!(await isFile(file)))
|
|
273
|
+
out.error({
|
|
274
|
+
code: "PORTAL_FILE_MISSING",
|
|
275
|
+
path: "portal.logo",
|
|
276
|
+
message: `the file ${path.relative(resolved.configDir, file)} does not exist.`,
|
|
277
|
+
hint: "Create it, or remove portal.logo.",
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
250
281
|
if (await isFile(portal.theme)) {
|
|
251
282
|
try {
|
|
252
283
|
JSON.parse(await readFile(portal.theme, "utf8"));
|
package/src/config/v2/schema.ts
CHANGED
|
@@ -47,6 +47,15 @@ export const PortalSchema = z.strictObject({
|
|
|
47
47
|
theme: path.optional(),
|
|
48
48
|
/** A custom home page: a React component file that the portal serves at `/`. */
|
|
49
49
|
landing: path.optional(),
|
|
50
|
+
/** The footer: a React component file shown under every page except the console's (API-129). */
|
|
51
|
+
footer: path.optional(),
|
|
52
|
+
/** The logo in the portal header: one image, or one per color scheme (API-129). */
|
|
53
|
+
logo: z
|
|
54
|
+
.union([
|
|
55
|
+
path,
|
|
56
|
+
z.strictObject({ light: path, dark: path, alt: z.string().optional() }),
|
|
57
|
+
])
|
|
58
|
+
.optional(),
|
|
50
59
|
requireAuth: z
|
|
51
60
|
.union([z.boolean(), z.strictObject({ exclude: pathList })], {
|
|
52
61
|
error: 'must be true, false or { "exclude": ["/route", ...] }',
|
|
@@ -26,7 +26,7 @@ import { parseConfigV2 } from "./parse.js";
|
|
|
26
26
|
import { type ResolvedConfigV2, resolveConfigV2 } from "./resolve.js";
|
|
27
27
|
import { checkDependencies } from "./rules.js";
|
|
28
28
|
import type { ConfigV2 } from "./schema.js";
|
|
29
|
-
import { starterLandingPage } from "./starter-landing.js";
|
|
29
|
+
import { starterFooter, starterLandingPage } from "./starter-landing.js";
|
|
30
30
|
|
|
31
31
|
export const SERVICES = ["portal", "gateway", "identity", "plans"] as const;
|
|
32
32
|
export type ServiceName = (typeof SERVICES)[number];
|
|
@@ -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
|
},
|
|
@@ -352,6 +351,29 @@ async function addFile(context: Context, target: string, content: string) {
|
|
|
352
351
|
else context.files.push({ path: target, content });
|
|
353
352
|
}
|
|
354
353
|
|
|
354
|
+
/**
|
|
355
|
+
* The portal's own colors, the same as the built-in theme, so the file changes nothing until it is edited. Any color
|
|
356
|
+
* of the theme can be added here (see Colors & theme in the docs).
|
|
357
|
+
*/
|
|
358
|
+
export const STARTER_THEME = `${JSON.stringify(
|
|
359
|
+
{
|
|
360
|
+
light: {
|
|
361
|
+
primary: "#0d9488",
|
|
362
|
+
primaryForeground: "#ffffff",
|
|
363
|
+
ring: "#0d9488",
|
|
364
|
+
radius: "0.625rem",
|
|
365
|
+
},
|
|
366
|
+
dark: {
|
|
367
|
+
primary: "#0d9488",
|
|
368
|
+
primaryForeground: "#ffffff",
|
|
369
|
+
ring: "#0d9488",
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
null,
|
|
373
|
+
2,
|
|
374
|
+
)}
|
|
375
|
+
`;
|
|
376
|
+
|
|
355
377
|
async function buildPortal(
|
|
356
378
|
context: Context,
|
|
357
379
|
): Promise<NonNullable<ConfigV2["portal"]>> {
|
|
@@ -411,6 +433,8 @@ async function buildPortal(
|
|
|
411
433
|
{ value: "blank", label: "An empty pages folder" },
|
|
412
434
|
])) as "starter" | "blank";
|
|
413
435
|
const pages = path.join(resolved.root, "portal", "pages");
|
|
436
|
+
// Written out, not left to the default, so the config shows where the docs pages live (API-129).
|
|
437
|
+
content = [`${rel(dir, pages)}/**/*.{md,mdx}`];
|
|
414
438
|
if (template === "blank")
|
|
415
439
|
await addFile(context, path.join(pages, ".gitkeep"), "");
|
|
416
440
|
else if (isFilled(options.template)) {
|
|
@@ -438,14 +462,45 @@ async function buildPortal(
|
|
|
438
462
|
);
|
|
439
463
|
landing = rel(dir, landingFile);
|
|
440
464
|
} else
|
|
465
|
+
// Not `index.mdx`: an index page answers at `/`, which a home page added later would lose (API-129).
|
|
441
466
|
await addFile(
|
|
442
467
|
context,
|
|
443
|
-
path.join(pages, "
|
|
468
|
+
path.join(pages, "introduction.mdx"),
|
|
444
469
|
starterPage(name),
|
|
445
470
|
);
|
|
446
471
|
}
|
|
472
|
+
// Always there, so the brand colors are one edit away instead of a docs search (API-129).
|
|
473
|
+
await addFile(
|
|
474
|
+
context,
|
|
475
|
+
path.join(resolved.root, "portal", "theme.json"),
|
|
476
|
+
STARTER_THEME,
|
|
477
|
+
);
|
|
447
478
|
}
|
|
448
479
|
|
|
480
|
+
// A footer the project owns, like the home page: the config points to it, so it can be changed or removed (API-129).
|
|
481
|
+
const footerFile = path.join(resolved.root, "portal", "footer", "Footer.tsx");
|
|
482
|
+
await addFile(
|
|
483
|
+
context,
|
|
484
|
+
footerFile,
|
|
485
|
+
starterFooter({
|
|
486
|
+
name,
|
|
487
|
+
links: [
|
|
488
|
+
...(isFilled(options.template) && !external
|
|
489
|
+
? [{ label: "Documentation", href: "/introduction" }]
|
|
490
|
+
: []),
|
|
491
|
+
...(context.config.gateway
|
|
492
|
+
? [{ label: "API Reference", href: "/api" }]
|
|
493
|
+
: []),
|
|
494
|
+
...(context.config.plans && context.config.plans.enabled !== false
|
|
495
|
+
? [{ label: "Pricing", href: "/pricing" }]
|
|
496
|
+
: []),
|
|
497
|
+
...(context.config.identity && !o.noConsole
|
|
498
|
+
? [{ label: "Sign in", href: "/login" }]
|
|
499
|
+
: []),
|
|
500
|
+
],
|
|
501
|
+
}),
|
|
502
|
+
);
|
|
503
|
+
|
|
449
504
|
return {
|
|
450
505
|
...(options.disabled ? { enabled: false } : {}),
|
|
451
506
|
name,
|
|
@@ -455,6 +510,7 @@ async function buildPortal(
|
|
|
455
510
|
? { docs: { content } }
|
|
456
511
|
: {}),
|
|
457
512
|
...(landing ? { landing } : {}),
|
|
513
|
+
footer: rel(dir, footerFile),
|
|
458
514
|
...(o.noConsole ? { console: { enabled: false } } : {}),
|
|
459
515
|
...(o.requireAuth ? { requireAuth: true } : {}),
|
|
460
516
|
...(o.search && !external ? { search: o.search } : {}),
|
|
@@ -1049,6 +1105,25 @@ export async function setProjectId(
|
|
|
1049
1105
|
return true;
|
|
1050
1106
|
}
|
|
1051
1107
|
|
|
1108
|
+
/**
|
|
1109
|
+
* Points the folder at another project for good (`apitogo project use`, API-130): `project.id` becomes the given id,
|
|
1110
|
+
* whatever it was. Returns the id it replaced, if any.
|
|
1111
|
+
*/
|
|
1112
|
+
export async function replaceProjectId(
|
|
1113
|
+
dir: string,
|
|
1114
|
+
projectId: string,
|
|
1115
|
+
): Promise<string | undefined> {
|
|
1116
|
+
const { config, resolved } = loadConfigV2(dir);
|
|
1117
|
+
const previous = config.project?.id;
|
|
1118
|
+
if (previous === projectId) return previous;
|
|
1119
|
+
const next = reparse(resolved.configPath, {
|
|
1120
|
+
...config,
|
|
1121
|
+
project: { ...config.project, id: projectId },
|
|
1122
|
+
});
|
|
1123
|
+
await writeConfig(resolved.configPath, next);
|
|
1124
|
+
return previous;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1052
1127
|
/** Removes `project.id` when it is the given id: the project is gone, so the link would only point at nothing. */
|
|
1053
1128
|
export async function clearProjectId(
|
|
1054
1129
|
dir: string,
|
|
@@ -155,3 +155,44 @@ ${plansSection} <PricingCtaSection
|
|
|
155
155
|
}
|
|
156
156
|
`;
|
|
157
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The portal's footer, shown under every page except the console's. Built from the landing module's footer section so
|
|
161
|
+
* it matches the home page; the project owns the file, and `portal.footer` in the config points to it (API-129).
|
|
162
|
+
*/
|
|
163
|
+
export function starterFooter(options: {
|
|
164
|
+
name: string;
|
|
165
|
+
links: { label: string; href: string }[];
|
|
166
|
+
}): string {
|
|
167
|
+
const links = options.links
|
|
168
|
+
.map(
|
|
169
|
+
(link) =>
|
|
170
|
+
` { label: ${JSON.stringify(link.label)}, href: ${JSON.stringify(link.href)} },`,
|
|
171
|
+
)
|
|
172
|
+
.join("\n");
|
|
173
|
+
return `// Your portal's footer, shown under every page except the console's. Edit this file to make it yours, or remove
|
|
174
|
+
// "footer" from portal in apitogo.config.json to have none.
|
|
175
|
+
import { FooterSection } from "@lukoweb/apitogo-module-landing";
|
|
176
|
+
|
|
177
|
+
const NAME = ${JSON.stringify(options.name)};
|
|
178
|
+
|
|
179
|
+
export default function Footer() {
|
|
180
|
+
return (
|
|
181
|
+
<FooterSection
|
|
182
|
+
footer={{
|
|
183
|
+
brand: { name: NAME, tagline: "Documentation, API keys and plans for the ${options.name.replace(/[`$\\"]/g, "")} API." },
|
|
184
|
+
columns: [
|
|
185
|
+
{
|
|
186
|
+
title: "Developers",
|
|
187
|
+
links: [
|
|
188
|
+
${links}
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
copyright: \`© \${new Date().getFullYear()} \${NAME}\`,
|
|
193
|
+
}}
|
|
194
|
+
/>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
`;
|
|
198
|
+
}
|
package/src/config/v2/wrapper.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { extractGatewayActionKeysFromOpenApiFile } from "../../cli/platform/helpers/openapi-action-keys.js";
|
|
3
4
|
import { docsNavigation } from "./docs-navigation.js";
|
|
4
5
|
import { ConfigError, type ConfigIssue } from "./errors.js";
|
|
5
6
|
import {
|
|
@@ -23,7 +24,7 @@ import {
|
|
|
23
24
|
} from "./manifest.js";
|
|
24
25
|
import { type PortalProfile, portalProfile } from "./portal-profile.js";
|
|
25
26
|
import { protectedRoutesFor } from "./protected-routes.js";
|
|
26
|
-
import type
|
|
27
|
+
import { isLogoUrl, type ResolvedConfigV2 } from "./resolve.js";
|
|
27
28
|
import { findTsxPages, TSX_PAGE_ELEMENT_PREFIX } from "./tsx-pages.js";
|
|
28
29
|
|
|
29
30
|
export const WRAPPER_FILE = "apitogo.config.tsx";
|
|
@@ -80,6 +81,8 @@ export type PortalPlan = {
|
|
|
80
81
|
clientId: string;
|
|
81
82
|
scopes: string[];
|
|
82
83
|
audience?: string;
|
|
84
|
+
/** Try it offers "Signed-in user" only when the gateway checks tokens (API-129). */
|
|
85
|
+
tokenIdentity?: boolean;
|
|
83
86
|
};
|
|
84
87
|
/** Pricing, subscriptions, API keys and usage pages. */
|
|
85
88
|
plansPlugin?: {
|
|
@@ -94,6 +97,12 @@ export type PortalPlan = {
|
|
|
94
97
|
keyHeader?: string;
|
|
95
98
|
/** The gateway's address, for code samples on the portal's pages. */
|
|
96
99
|
gatewayBaseUrl?: string;
|
|
100
|
+
/** The plan shown as "Popular": only the one the tenant marked `featured` in plans.json (API-124). */
|
|
101
|
+
featuredPlanSku?: string;
|
|
102
|
+
/** Try it offers "Signed-in user" only when the gateway checks tokens (API-129). */
|
|
103
|
+
tokenIdentity?: boolean;
|
|
104
|
+
/** Lines each plan's card lists, by lowercased sku, from `features` in plans.json (API-129). */
|
|
105
|
+
planFeatures?: Record<string, string[]>;
|
|
97
106
|
};
|
|
98
107
|
/** Account pages. */
|
|
99
108
|
userPanel: boolean;
|
|
@@ -341,6 +350,7 @@ export function buildSiteConfig(
|
|
|
341
350
|
return {
|
|
342
351
|
site: {
|
|
343
352
|
title: portal?.name ?? resolved.project.name,
|
|
353
|
+
...(portal?.logo ? { logo: siteLogo(portal) } : {}),
|
|
344
354
|
...(plan.preview
|
|
345
355
|
? { banner: { message: PREVIEW_BANNER_MESSAGE, color: "caution" } }
|
|
346
356
|
: {}),
|
|
@@ -446,6 +456,32 @@ function authenticationFor(plan: PortalPlan): Record<string, unknown> {
|
|
|
446
456
|
export const PREVIEW_BANNER_MESSAGE =
|
|
447
457
|
"Preview mode: sign in with demo@apitogo.local / password to try the console. Run `apitogo deploy` for the real services.";
|
|
448
458
|
|
|
459
|
+
/**
|
|
460
|
+
* The header logo as the site takes it: a file of the portal's `public/` folder becomes its address on the site, an https
|
|
461
|
+
* address stays as it is (API-129). `check` refuses a file outside `public/`.
|
|
462
|
+
*/
|
|
463
|
+
function siteLogo(
|
|
464
|
+
portal: NonNullable<ResolvedConfigV2["portal"]>,
|
|
465
|
+
): Record<string, unknown> {
|
|
466
|
+
const logo = portal.logo as NonNullable<typeof portal.logo>;
|
|
467
|
+
const publicDir = path.join(portal.dir, "public");
|
|
468
|
+
const src = (file: string) =>
|
|
469
|
+
isLogoUrl(file)
|
|
470
|
+
? file
|
|
471
|
+
: `/${path.relative(publicDir, file).split(path.sep).join("/")}`;
|
|
472
|
+
return {
|
|
473
|
+
src: { light: src(logo.light), dark: src(logo.dark) },
|
|
474
|
+
alt: logo.alt ?? portal.name,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** A content path as the wrapper imports it: through the `content` link next to it, or absolute when outside it. */
|
|
479
|
+
const wrapperImport = (file: string) =>
|
|
480
|
+
file.startsWith(`${CONTENT_LINK}/`) ? `./${file}` : file;
|
|
481
|
+
|
|
482
|
+
/** Paths of the console, where the footer is not shown (API-129). */
|
|
483
|
+
export const FOOTER_HIDDEN_PATHS = ["/account", "/login", "/signup"];
|
|
484
|
+
|
|
449
485
|
/** A React docs page the wrapper imports: `importPath` is relative to the wrapper, through the `content` link. */
|
|
450
486
|
export type WrapperPage = { id: string; importPath: string };
|
|
451
487
|
|
|
@@ -456,6 +492,8 @@ export type WrapperPage = { id: string; importPath: string };
|
|
|
456
492
|
export function renderWrapper(
|
|
457
493
|
plan: PortalPlan,
|
|
458
494
|
pages: readonly WrapperPage[] = [],
|
|
495
|
+
/** The portal's footer component (`portal.footer`), relative to the wrapper through the `content` link. */
|
|
496
|
+
footerImportPath?: string,
|
|
459
497
|
): string {
|
|
460
498
|
const imports = [
|
|
461
499
|
'import { buildApitogoConfig } from "@lukoweb/apitogo/build-config";',
|
|
@@ -464,7 +502,12 @@ export function renderWrapper(
|
|
|
464
502
|
'import { devPortalBillingPlugin } from "@lukoweb/apitogo-plugin-dev-portal-billing";',
|
|
465
503
|
]
|
|
466
504
|
: []),
|
|
467
|
-
...(pages.length > 0
|
|
505
|
+
...(pages.length > 0 || footerImportPath
|
|
506
|
+
? ['import { createElement } from "react";']
|
|
507
|
+
: []),
|
|
508
|
+
...(footerImportPath
|
|
509
|
+
? [`import PortalFooter from ${JSON.stringify(footerImportPath)};`]
|
|
510
|
+
: []),
|
|
468
511
|
...pages.map(
|
|
469
512
|
(page, index) =>
|
|
470
513
|
`import TsxPage${index} from ${JSON.stringify(page.importPath)};`,
|
|
@@ -511,7 +554,22 @@ export function renderWrapper(
|
|
|
511
554
|
"",
|
|
512
555
|
// The components go in after buildApitogoConfig: it copies the config with structuredClone, which cannot copy a
|
|
513
556
|
// React element.
|
|
514
|
-
...(
|
|
557
|
+
...(footerImportPath
|
|
558
|
+
? [
|
|
559
|
+
"",
|
|
560
|
+
"// The portal's footer (portal.footer) on every page except the console's.",
|
|
561
|
+
`const FOOTER_HIDDEN_PATHS = ${JSON.stringify(FOOTER_HIDDEN_PATHS)};`,
|
|
562
|
+
"const footerSlot = (props: any) =>",
|
|
563
|
+
" FOOTER_HIDDEN_PATHS.some(",
|
|
564
|
+
" (hidden) =>",
|
|
565
|
+
" props.location.pathname === hidden ||",
|
|
566
|
+
" props.location.pathname.startsWith(`${hidden}/`),",
|
|
567
|
+
" )",
|
|
568
|
+
" ? null",
|
|
569
|
+
" : createElement(PortalFooter, props);",
|
|
570
|
+
]
|
|
571
|
+
: []),
|
|
572
|
+
...(pages.length > 0 || footerImportPath
|
|
515
573
|
? [
|
|
516
574
|
"const config = buildApitogoConfig({",
|
|
517
575
|
" ...siteConfig,",
|
|
@@ -520,7 +578,12 @@ export function renderWrapper(
|
|
|
520
578
|
"",
|
|
521
579
|
"export default {",
|
|
522
580
|
" ...config,",
|
|
523
|
-
|
|
581
|
+
...(pages.length > 0
|
|
582
|
+
? [" navigation: withTsxPages((config as any).navigation ?? []),"]
|
|
583
|
+
: []),
|
|
584
|
+
...(footerImportPath
|
|
585
|
+
? [" slots: { ...(config as any).slots, footer: footerSlot },"]
|
|
586
|
+
: []),
|
|
524
587
|
"};",
|
|
525
588
|
]
|
|
526
589
|
: [
|
|
@@ -551,6 +614,30 @@ async function readDemoPlans(
|
|
|
551
614
|
}
|
|
552
615
|
}
|
|
553
616
|
|
|
617
|
+
/**
|
|
618
|
+
* The endpoints each preview plan lists, as the deployed portal does (API-129). A plan that names actions
|
|
619
|
+
* (`includedActions`) lists those; one that names none gets every operation of the gateway's OpenAPI files, which is
|
|
620
|
+
* what deploy gives it too.
|
|
621
|
+
*/
|
|
622
|
+
export function withPreviewActions(
|
|
623
|
+
plans: unknown[] | undefined,
|
|
624
|
+
specs: readonly string[],
|
|
625
|
+
): unknown[] | undefined {
|
|
626
|
+
if (!plans) return undefined;
|
|
627
|
+
let all: string[] | undefined;
|
|
628
|
+
return plans.map((item) => {
|
|
629
|
+
if (typeof item !== "object" || item === null) return item;
|
|
630
|
+
const named = (item as { includedActions?: unknown }).includedActions;
|
|
631
|
+
all ??= [
|
|
632
|
+
...new Set(specs.flatMap(extractGatewayActionKeysFromOpenApiFile)),
|
|
633
|
+
].sort((a, b) => a.localeCompare(b));
|
|
634
|
+
return {
|
|
635
|
+
...item,
|
|
636
|
+
includedActionKeys: Array.isArray(named) ? named : all,
|
|
637
|
+
};
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
554
641
|
/** The portal theme file, when it exists and parses. `check` reports an invalid one; the build just skips it. */
|
|
555
642
|
async function readTheme(resolved: ResolvedConfigV2): Promise<unknown> {
|
|
556
643
|
const file = resolved.portal?.theme;
|
|
@@ -685,12 +772,49 @@ export async function preparePortal(
|
|
|
685
772
|
for (const page of tsxPages)
|
|
686
773
|
if (page.order !== undefined && !pageOrders.has(page.route))
|
|
687
774
|
pageOrders.set(page.route, page.order);
|
|
775
|
+
// "Popular" is the tenant's choice, never guessed (API-124).
|
|
776
|
+
if (plan.plansPlugin) {
|
|
777
|
+
const featured = (await readDemoPlans(resolved.plans?.file))?.find(
|
|
778
|
+
(item): item is { sku: string } =>
|
|
779
|
+
typeof item === "object" &&
|
|
780
|
+
item !== null &&
|
|
781
|
+
(item as { featured?: unknown }).featured === true &&
|
|
782
|
+
typeof (item as { sku?: unknown }).sku === "string",
|
|
783
|
+
);
|
|
784
|
+
if (featured)
|
|
785
|
+
plan.plansPlugin.featuredPlanSku = featured.sku.trim().toLowerCase();
|
|
786
|
+
}
|
|
787
|
+
// The cards' own lines, from plans.json: the platform has no such field, so the build brings them (API-129).
|
|
788
|
+
if (plan.plansPlugin) {
|
|
789
|
+
const planFeatures: Record<string, string[]> = {};
|
|
790
|
+
for (const item of (await readDemoPlans(resolved.plans?.file)) ?? []) {
|
|
791
|
+
const { sku, features } = (item ?? {}) as {
|
|
792
|
+
sku?: unknown;
|
|
793
|
+
features?: unknown;
|
|
794
|
+
};
|
|
795
|
+
if (typeof sku !== "string" || !Array.isArray(features)) continue;
|
|
796
|
+
const lines = features.filter(
|
|
797
|
+
(line): line is string =>
|
|
798
|
+
typeof line === "string" && line.trim().length > 0,
|
|
799
|
+
);
|
|
800
|
+
if (lines.length > 0) planFeatures[sku.trim().toLowerCase()] = lines;
|
|
801
|
+
}
|
|
802
|
+
if (Object.keys(planFeatures).length > 0)
|
|
803
|
+
plan.plansPlugin.planFeatures = planFeatures;
|
|
804
|
+
}
|
|
805
|
+
// "Signed-in user" in Try it sends the visitor's token, which only a gateway with `gateway.auth` reads (API-129).
|
|
806
|
+
const tokenIdentity = resolved.gateway?.auth !== undefined;
|
|
807
|
+
if (plan.plansPlugin) plan.plansPlugin.tokenIdentity = tokenIdentity;
|
|
808
|
+
if (plan.auth) plan.auth.tokenIdentity = tokenIdentity;
|
|
688
809
|
// The account pages' "Documentation" link goes to a page the portal really has, or is left out.
|
|
689
810
|
if (plan.plansPlugin && plan.docs) {
|
|
811
|
+
// With a home page of its own, `/` is the home page and an index page stays at `/index` (API-129).
|
|
690
812
|
const landing = hostedDocRoutes.some(
|
|
691
813
|
(route) => route === "/" || route === "/index",
|
|
692
814
|
)
|
|
693
|
-
? "/"
|
|
815
|
+
? resolved.portal?.landing && !hostedDocRoutes.includes("/")
|
|
816
|
+
? "/index"
|
|
817
|
+
: "/"
|
|
694
818
|
: pickLandingRoute(hostedDocRoutes, pageOrders);
|
|
695
819
|
if (landing)
|
|
696
820
|
plan.plansPlugin.links = { ...plan.plansPlugin.links, docs: landing };
|
|
@@ -726,11 +850,15 @@ export async function preparePortal(
|
|
|
726
850
|
element: wrapperPages[index]?.id ?? "",
|
|
727
851
|
})),
|
|
728
852
|
pageOrders,
|
|
853
|
+
{ homeTaken: resolved.portal?.landing !== undefined },
|
|
729
854
|
),
|
|
730
855
|
docsPath: pickLandingRoute(hostedDocRoutes, pageOrders),
|
|
731
856
|
demoPlans:
|
|
732
857
|
plan.demo && plan.plansPlugin
|
|
733
|
-
?
|
|
858
|
+
? withPreviewActions(
|
|
859
|
+
await readDemoPlans(resolved.plans?.file),
|
|
860
|
+
resolved.gateway?.specs ?? [],
|
|
861
|
+
)
|
|
734
862
|
: undefined,
|
|
735
863
|
protectedRoutes: protectedRoutesFor({
|
|
736
864
|
requireAuth: resolved.portal?.requireAuth,
|
|
@@ -742,7 +870,13 @@ export async function preparePortal(
|
|
|
742
870
|
}),
|
|
743
871
|
},
|
|
744
872
|
);
|
|
745
|
-
const wrapper = renderWrapper(
|
|
873
|
+
const wrapper = renderWrapper(
|
|
874
|
+
plan,
|
|
875
|
+
wrapperPages,
|
|
876
|
+
resolved.portal?.footer
|
|
877
|
+
? wrapperImport(contentFile(resolved.configDir, resolved.portal.footer))
|
|
878
|
+
: undefined,
|
|
879
|
+
);
|
|
746
880
|
const wrapperPath = path.join(resolved.state.generatedDir, WRAPPER_FILE);
|
|
747
881
|
if (options.write !== false) {
|
|
748
882
|
await mkdir(path.dirname(wrapperPath), { recursive: true });
|