@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.
Files changed (82) hide show
  1. package/dist/cli/cli.js +2196 -1352
  2. package/dist/declarations/cli/platform/client/platform-api.d.ts +29 -0
  3. package/dist/declarations/cli/platform/helpers/platform-plan-catalog.d.ts +6 -0
  4. package/dist/declarations/config/validators/ZudokuConfig.d.ts +2 -0
  5. package/dist/declarations/lib/authentication/providers/openid.d.ts +2 -1
  6. package/dist/declarations/lib/plugins/markdown/index.d.ts +2 -1
  7. package/dist/flat-config.d.ts +1 -0
  8. package/docs/cli/commands.md +50 -32
  9. package/docs/cli/go-live.md +66 -93
  10. package/docs/cli/overview.md +18 -17
  11. package/docs/cli/setup.md +130 -141
  12. package/docs/cli/shape.md +94 -11
  13. package/docs/cli/walkthrough.md +1 -1
  14. package/docs/components/slot.mdx +116 -119
  15. package/docs/configuration/api-reference.md +98 -397
  16. package/docs/configuration/authentication.md +118 -159
  17. package/docs/configuration/footer.mdx +89 -118
  18. package/docs/configuration/make-config.mdx +2 -2
  19. package/docs/configuration/overview.md +97 -166
  20. package/docs/configuration/plans.md +100 -110
  21. package/docs/configuration/portal.md +116 -0
  22. package/docs/customization/colors-theme.mdx +255 -278
  23. package/docs/customization/fonts.md +105 -110
  24. package/docs/customization/landing-page.md +189 -188
  25. package/docs/deploy/apitogo.md +13 -17
  26. package/docs/deployment.md +15 -9
  27. package/docs/guides/stripe-setup.mdx +123 -0
  28. package/docs/markdown/frontmatter.md +172 -173
  29. package/docs/mcp/go-live.md +100 -100
  30. package/docs/mcp/overview.md +76 -76
  31. package/docs/quickstart.md +88 -72
  32. package/docs/writing.mdx +68 -72
  33. package/package.json +1 -1
  34. package/src/config/v2/docs-navigation.ts +7 -2
  35. package/src/config/v2/index.ts +1 -0
  36. package/src/config/v2/resolve.ts +28 -0
  37. package/src/config/v2/rules.ts +32 -1
  38. package/src/config/v2/schema.ts +9 -0
  39. package/src/config/v2/services.ts +90 -15
  40. package/src/config/v2/starter-landing.ts +41 -0
  41. package/src/config/v2/wrapper.ts +141 -7
  42. package/src/config/validators/ZudokuConfig.ts +794 -792
  43. package/src/lib/authentication/providers/openid.tsx +6 -1
  44. package/src/lib/plugins/markdown/index.tsx +86 -82
  45. package/src/vite/dev-server.ts +9 -1
  46. package/src/vite/dev-ssr-config.ts +32 -0
  47. package/src/vite/plugin-config.ts +4 -2
  48. package/src/vite/plugin-docs.ts +213 -205
  49. package/docs/concepts/auth-provider-api-identities.md +0 -105
  50. package/docs/configuration/ai-assistants.md +0 -65
  51. package/docs/configuration/api-catalog.md +0 -109
  52. package/docs/configuration/authentication-auth0.md +0 -181
  53. package/docs/configuration/authentication-azure-ad.md +0 -245
  54. package/docs/configuration/authentication-clerk.md +0 -117
  55. package/docs/configuration/authentication-firebase.md +0 -69
  56. package/docs/configuration/authentication-pingfederate.md +0 -143
  57. package/docs/configuration/authentication-supabase.md +0 -233
  58. package/docs/configuration/build-configuration.mdx +0 -147
  59. package/docs/configuration/docs.md +0 -282
  60. package/docs/configuration/llms.md +0 -90
  61. package/docs/configuration/manifest.md +0 -129
  62. package/docs/configuration/navigation.mdx +0 -415
  63. package/docs/configuration/protected-routes.md +0 -150
  64. package/docs/configuration/search.md +0 -164
  65. package/docs/configuration/sentry.mdx +0 -44
  66. package/docs/configuration/site.md +0 -124
  67. package/docs/configuration/slots.mdx +0 -125
  68. package/docs/configuration/vite-config.md +0 -18
  69. package/docs/custom-plugins.md +0 -285
  70. package/docs/deploy/apache-nginx.md +0 -41
  71. package/docs/deploy/cloudflare-pages.md +0 -74
  72. package/docs/deploy/direct-upload.md +0 -17
  73. package/docs/deploy/github-pages.md +0 -49
  74. package/docs/deploy/vercel.md +0 -101
  75. package/docs/extending/events.md +0 -124
  76. package/docs/guides/environment-variables.md +0 -94
  77. package/docs/guides/managing-api-keys-and-identities.md +0 -89
  78. package/docs/guides/navigation-rules.mdx +0 -203
  79. package/docs/guides/processors.mdx +0 -234
  80. package/docs/guides/transforming-examples.md +0 -156
  81. package/docs/guides/using-multiple-apis.md +0 -87
  82. 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.54",
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];
@@ -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 3 calls per 60 seconds per plan; a higher number is refused.
253
- rateLimit: { calls: 3, periodSeconds: 60 },
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 starter: a free plan and two with a price on request, so they show on the pricing page and
259
- // the home page and still deploy on a Free project (a plan with a price needs a paid platform plan and billing).
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: 3, periodSeconds: 60 },
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
- sku: "enterprise",
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 3 calls per 60 seconds per plan; a higher number is refused.
290
- rateLimit: { calls: 3, periodSeconds: 60 },
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, "index.mdx"),
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
+ }
@@ -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?: {
@@ -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 ? ['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
+ : []),
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
- ...(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
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
- " 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
+ : []),
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
- ? await readDemoPlans(resolved.plans?.file)
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(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
+ );
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 });