@lukoweb/apitogo 0.2.56 → 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 +1026 -540
- package/dist/declarations/cli/platform/client/platform-api.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/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 +78 -2
- package/src/config/v2/starter-landing.ts +41 -0
- package/src/config/v2/wrapper.ts +127 -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];
|
|
@@ -351,6 +351,29 @@ async function addFile(context: Context, target: string, content: string) {
|
|
|
351
351
|
else context.files.push({ path: target, content });
|
|
352
352
|
}
|
|
353
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
|
+
|
|
354
377
|
async function buildPortal(
|
|
355
378
|
context: Context,
|
|
356
379
|
): Promise<NonNullable<ConfigV2["portal"]>> {
|
|
@@ -410,6 +433,8 @@ async function buildPortal(
|
|
|
410
433
|
{ value: "blank", label: "An empty pages folder" },
|
|
411
434
|
])) as "starter" | "blank";
|
|
412
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}`];
|
|
413
438
|
if (template === "blank")
|
|
414
439
|
await addFile(context, path.join(pages, ".gitkeep"), "");
|
|
415
440
|
else if (isFilled(options.template)) {
|
|
@@ -437,14 +462,45 @@ async function buildPortal(
|
|
|
437
462
|
);
|
|
438
463
|
landing = rel(dir, landingFile);
|
|
439
464
|
} else
|
|
465
|
+
// Not `index.mdx`: an index page answers at `/`, which a home page added later would lose (API-129).
|
|
440
466
|
await addFile(
|
|
441
467
|
context,
|
|
442
|
-
path.join(pages, "
|
|
468
|
+
path.join(pages, "introduction.mdx"),
|
|
443
469
|
starterPage(name),
|
|
444
470
|
);
|
|
445
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
|
+
);
|
|
446
478
|
}
|
|
447
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
|
+
|
|
448
504
|
return {
|
|
449
505
|
...(options.disabled ? { enabled: false } : {}),
|
|
450
506
|
name,
|
|
@@ -454,6 +510,7 @@ async function buildPortal(
|
|
|
454
510
|
? { docs: { content } }
|
|
455
511
|
: {}),
|
|
456
512
|
...(landing ? { landing } : {}),
|
|
513
|
+
footer: rel(dir, footerFile),
|
|
457
514
|
...(o.noConsole ? { console: { enabled: false } } : {}),
|
|
458
515
|
...(o.requireAuth ? { requireAuth: true } : {}),
|
|
459
516
|
...(o.search && !external ? { search: o.search } : {}),
|
|
@@ -1048,6 +1105,25 @@ export async function setProjectId(
|
|
|
1048
1105
|
return true;
|
|
1049
1106
|
}
|
|
1050
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
|
+
|
|
1051
1127
|
/** Removes `project.id` when it is the given id: the project is gone, so the link would only point at nothing. */
|
|
1052
1128
|
export async function clearProjectId(
|
|
1053
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?: {
|
|
@@ -96,6 +99,10 @@ export type PortalPlan = {
|
|
|
96
99
|
gatewayBaseUrl?: string;
|
|
97
100
|
/** The plan shown as "Popular": only the one the tenant marked `featured` in plans.json (API-124). */
|
|
98
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[]>;
|
|
99
106
|
};
|
|
100
107
|
/** Account pages. */
|
|
101
108
|
userPanel: boolean;
|
|
@@ -343,6 +350,7 @@ export function buildSiteConfig(
|
|
|
343
350
|
return {
|
|
344
351
|
site: {
|
|
345
352
|
title: portal?.name ?? resolved.project.name,
|
|
353
|
+
...(portal?.logo ? { logo: siteLogo(portal) } : {}),
|
|
346
354
|
...(plan.preview
|
|
347
355
|
? { banner: { message: PREVIEW_BANNER_MESSAGE, color: "caution" } }
|
|
348
356
|
: {}),
|
|
@@ -448,6 +456,32 @@ function authenticationFor(plan: PortalPlan): Record<string, unknown> {
|
|
|
448
456
|
export const PREVIEW_BANNER_MESSAGE =
|
|
449
457
|
"Preview mode: sign in with demo@apitogo.local / password to try the console. Run `apitogo deploy` for the real services.";
|
|
450
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
|
+
|
|
451
485
|
/** A React docs page the wrapper imports: `importPath` is relative to the wrapper, through the `content` link. */
|
|
452
486
|
export type WrapperPage = { id: string; importPath: string };
|
|
453
487
|
|
|
@@ -458,6 +492,8 @@ export type WrapperPage = { id: string; importPath: string };
|
|
|
458
492
|
export function renderWrapper(
|
|
459
493
|
plan: PortalPlan,
|
|
460
494
|
pages: readonly WrapperPage[] = [],
|
|
495
|
+
/** The portal's footer component (`portal.footer`), relative to the wrapper through the `content` link. */
|
|
496
|
+
footerImportPath?: string,
|
|
461
497
|
): string {
|
|
462
498
|
const imports = [
|
|
463
499
|
'import { buildApitogoConfig } from "@lukoweb/apitogo/build-config";',
|
|
@@ -466,7 +502,12 @@ export function renderWrapper(
|
|
|
466
502
|
'import { devPortalBillingPlugin } from "@lukoweb/apitogo-plugin-dev-portal-billing";',
|
|
467
503
|
]
|
|
468
504
|
: []),
|
|
469
|
-
...(pages.length > 0
|
|
505
|
+
...(pages.length > 0 || footerImportPath
|
|
506
|
+
? ['import { createElement } from "react";']
|
|
507
|
+
: []),
|
|
508
|
+
...(footerImportPath
|
|
509
|
+
? [`import PortalFooter from ${JSON.stringify(footerImportPath)};`]
|
|
510
|
+
: []),
|
|
470
511
|
...pages.map(
|
|
471
512
|
(page, index) =>
|
|
472
513
|
`import TsxPage${index} from ${JSON.stringify(page.importPath)};`,
|
|
@@ -513,7 +554,22 @@ export function renderWrapper(
|
|
|
513
554
|
"",
|
|
514
555
|
// The components go in after buildApitogoConfig: it copies the config with structuredClone, which cannot copy a
|
|
515
556
|
// React element.
|
|
516
|
-
...(
|
|
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
|
|
517
573
|
? [
|
|
518
574
|
"const config = buildApitogoConfig({",
|
|
519
575
|
" ...siteConfig,",
|
|
@@ -522,7 +578,12 @@ export function renderWrapper(
|
|
|
522
578
|
"",
|
|
523
579
|
"export default {",
|
|
524
580
|
" ...config,",
|
|
525
|
-
|
|
581
|
+
...(pages.length > 0
|
|
582
|
+
? [" navigation: withTsxPages((config as any).navigation ?? []),"]
|
|
583
|
+
: []),
|
|
584
|
+
...(footerImportPath
|
|
585
|
+
? [" slots: { ...(config as any).slots, footer: footerSlot },"]
|
|
586
|
+
: []),
|
|
526
587
|
"};",
|
|
527
588
|
]
|
|
528
589
|
: [
|
|
@@ -553,6 +614,30 @@ async function readDemoPlans(
|
|
|
553
614
|
}
|
|
554
615
|
}
|
|
555
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
|
+
|
|
556
641
|
/** The portal theme file, when it exists and parses. `check` reports an invalid one; the build just skips it. */
|
|
557
642
|
async function readTheme(resolved: ResolvedConfigV2): Promise<unknown> {
|
|
558
643
|
const file = resolved.portal?.theme;
|
|
@@ -699,12 +784,37 @@ export async function preparePortal(
|
|
|
699
784
|
if (featured)
|
|
700
785
|
plan.plansPlugin.featuredPlanSku = featured.sku.trim().toLowerCase();
|
|
701
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;
|
|
702
809
|
// The account pages' "Documentation" link goes to a page the portal really has, or is left out.
|
|
703
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).
|
|
704
812
|
const landing = hostedDocRoutes.some(
|
|
705
813
|
(route) => route === "/" || route === "/index",
|
|
706
814
|
)
|
|
707
|
-
? "/"
|
|
815
|
+
? resolved.portal?.landing && !hostedDocRoutes.includes("/")
|
|
816
|
+
? "/index"
|
|
817
|
+
: "/"
|
|
708
818
|
: pickLandingRoute(hostedDocRoutes, pageOrders);
|
|
709
819
|
if (landing)
|
|
710
820
|
plan.plansPlugin.links = { ...plan.plansPlugin.links, docs: landing };
|
|
@@ -740,11 +850,15 @@ export async function preparePortal(
|
|
|
740
850
|
element: wrapperPages[index]?.id ?? "",
|
|
741
851
|
})),
|
|
742
852
|
pageOrders,
|
|
853
|
+
{ homeTaken: resolved.portal?.landing !== undefined },
|
|
743
854
|
),
|
|
744
855
|
docsPath: pickLandingRoute(hostedDocRoutes, pageOrders),
|
|
745
856
|
demoPlans:
|
|
746
857
|
plan.demo && plan.plansPlugin
|
|
747
|
-
?
|
|
858
|
+
? withPreviewActions(
|
|
859
|
+
await readDemoPlans(resolved.plans?.file),
|
|
860
|
+
resolved.gateway?.specs ?? [],
|
|
861
|
+
)
|
|
748
862
|
: undefined,
|
|
749
863
|
protectedRoutes: protectedRoutesFor({
|
|
750
864
|
requireAuth: resolved.portal?.requireAuth,
|
|
@@ -756,7 +870,13 @@ export async function preparePortal(
|
|
|
756
870
|
}),
|
|
757
871
|
},
|
|
758
872
|
);
|
|
759
|
-
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
|
+
);
|
|
760
880
|
const wrapperPath = path.join(resolved.state.generatedDir, WRAPPER_FILE);
|
|
761
881
|
if (options.write !== false) {
|
|
762
882
|
await mkdir(path.dirname(wrapperPath), { recursive: true });
|