@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.
Files changed (80) hide show
  1. package/dist/cli/cli.js +1026 -540
  2. package/dist/declarations/cli/platform/client/platform-api.d.ts +6 -0
  3. package/dist/declarations/config/validators/ZudokuConfig.d.ts +2 -0
  4. package/dist/declarations/lib/authentication/providers/openid.d.ts +2 -1
  5. package/dist/declarations/lib/plugins/markdown/index.d.ts +2 -1
  6. package/dist/flat-config.d.ts +1 -0
  7. package/docs/cli/commands.md +50 -32
  8. package/docs/cli/go-live.md +66 -93
  9. package/docs/cli/overview.md +18 -17
  10. package/docs/cli/setup.md +130 -141
  11. package/docs/cli/shape.md +94 -11
  12. package/docs/components/slot.mdx +116 -119
  13. package/docs/configuration/api-reference.md +98 -397
  14. package/docs/configuration/authentication.md +118 -159
  15. package/docs/configuration/footer.mdx +89 -118
  16. package/docs/configuration/make-config.mdx +2 -2
  17. package/docs/configuration/overview.md +97 -166
  18. package/docs/configuration/plans.md +100 -110
  19. package/docs/configuration/portal.md +116 -0
  20. package/docs/customization/colors-theme.mdx +255 -278
  21. package/docs/customization/fonts.md +105 -110
  22. package/docs/customization/landing-page.md +189 -188
  23. package/docs/deploy/apitogo.md +13 -17
  24. package/docs/deployment.md +15 -9
  25. package/docs/guides/stripe-setup.mdx +123 -0
  26. package/docs/markdown/frontmatter.md +172 -173
  27. package/docs/mcp/go-live.md +100 -100
  28. package/docs/mcp/overview.md +76 -76
  29. package/docs/quickstart.md +88 -72
  30. package/docs/writing.mdx +68 -72
  31. package/package.json +1 -1
  32. package/src/config/v2/docs-navigation.ts +7 -2
  33. package/src/config/v2/index.ts +1 -0
  34. package/src/config/v2/resolve.ts +28 -0
  35. package/src/config/v2/rules.ts +32 -1
  36. package/src/config/v2/schema.ts +9 -0
  37. package/src/config/v2/services.ts +78 -2
  38. package/src/config/v2/starter-landing.ts +41 -0
  39. package/src/config/v2/wrapper.ts +127 -7
  40. package/src/config/validators/ZudokuConfig.ts +794 -792
  41. package/src/lib/authentication/providers/openid.tsx +6 -1
  42. package/src/lib/plugins/markdown/index.tsx +86 -82
  43. package/src/vite/dev-server.ts +9 -1
  44. package/src/vite/dev-ssr-config.ts +32 -0
  45. package/src/vite/plugin-config.ts +4 -2
  46. package/src/vite/plugin-docs.ts +213 -205
  47. package/docs/concepts/auth-provider-api-identities.md +0 -105
  48. package/docs/configuration/ai-assistants.md +0 -65
  49. package/docs/configuration/api-catalog.md +0 -109
  50. package/docs/configuration/authentication-auth0.md +0 -181
  51. package/docs/configuration/authentication-azure-ad.md +0 -245
  52. package/docs/configuration/authentication-clerk.md +0 -117
  53. package/docs/configuration/authentication-firebase.md +0 -69
  54. package/docs/configuration/authentication-pingfederate.md +0 -143
  55. package/docs/configuration/authentication-supabase.md +0 -233
  56. package/docs/configuration/build-configuration.mdx +0 -147
  57. package/docs/configuration/docs.md +0 -282
  58. package/docs/configuration/llms.md +0 -90
  59. package/docs/configuration/manifest.md +0 -129
  60. package/docs/configuration/navigation.mdx +0 -415
  61. package/docs/configuration/protected-routes.md +0 -150
  62. package/docs/configuration/search.md +0 -164
  63. package/docs/configuration/sentry.mdx +0 -44
  64. package/docs/configuration/site.md +0 -124
  65. package/docs/configuration/slots.mdx +0 -125
  66. package/docs/configuration/vite-config.md +0 -18
  67. package/docs/custom-plugins.md +0 -285
  68. package/docs/deploy/apache-nginx.md +0 -41
  69. package/docs/deploy/cloudflare-pages.md +0 -74
  70. package/docs/deploy/direct-upload.md +0 -17
  71. package/docs/deploy/github-pages.md +0 -49
  72. package/docs/deploy/vercel.md +0 -101
  73. package/docs/extending/events.md +0 -124
  74. package/docs/guides/environment-variables.md +0 -94
  75. package/docs/guides/managing-api-keys-and-identities.md +0 -89
  76. package/docs/guides/navigation-rules.mdx +0 -203
  77. package/docs/guides/processors.mdx +0 -234
  78. package/docs/guides/transforming-examples.md +0 -156
  79. package/docs/guides/using-multiple-apis.md +0 -87
  80. package/docs/plugins.md +0 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo",
3
- "version": "0.2.56",
3
+ "version": "0.2.57",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -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
- const folderPath = prefix === "" ? "/" : `/${prefix.slice(0, -1)}`;
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 }];
@@ -116,6 +116,7 @@ export {
116
116
  serviceLabel,
117
117
  clearProjectId,
118
118
  ServiceEditError,
119
+ replaceProjectId,
119
120
  setProjectId,
120
121
  type ServiceEdit,
121
122
  type ServiceName,
@@ -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
  };
@@ -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"));
@@ -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, "index.mdx"),
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
+ }
@@ -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 { ResolvedConfigV2 } from "./resolve.js";
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 ? ['import { createElement } from "react";'] : []),
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
- ...(pages.length > 0
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
- " navigation: withTsxPages((config as any).navigation ?? []),",
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
- ? await readDemoPlans(resolved.plans?.file)
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(plan, wrapperPages);
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 });