@lukoweb/apitogo 0.2.39 → 0.2.41

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.
@@ -1,4 +1,12 @@
1
- import { access, mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
1
+ import {
2
+ access,
3
+ mkdir,
4
+ readdir,
5
+ rename,
6
+ rm,
7
+ stat,
8
+ writeFile,
9
+ } from "node:fs/promises";
2
10
  import path from "node:path";
3
11
  import { ApitogoError } from "../../lib/util/invariant.js";
4
12
  import { docsFromPaths, starterPage } from "./init.js";
@@ -8,6 +16,7 @@ import { parseConfigV2 } from "./parse.js";
8
16
  import { type ResolvedConfigV2, resolveConfigV2 } from "./resolve.js";
9
17
  import { checkDependencies } from "./rules.js";
10
18
  import type { ConfigV2 } from "./schema.js";
19
+ import { starterLandingPage } from "./starter-landing.js";
11
20
 
12
21
  export const SERVICES = ["portal", "gateway", "identity", "plans"] as const;
13
22
  export type ServiceName = (typeof SERVICES)[number];
@@ -49,13 +58,52 @@ const httpsProblem = (value: string) =>
49
58
  ? undefined
50
59
  : "The address must start with https://";
51
60
 
52
- /** The runtime version a prompt suggests; the platform lists the others. */
53
- const DEFAULT_RUNTIME_VERSION = {
54
- nodejs: "NODE_22_LTS",
55
- python: "PYTHON_312",
56
- dotnet: "DOTNET_8",
61
+ /** The runtime versions the platform runs, newest last; the first of each list is the one a prompt suggests. */
62
+ const RUNTIME_VERSIONS = {
63
+ nodejs: [
64
+ { value: "NODE_22_LTS", label: "Node.js 22 (LTS)" },
65
+ { value: "NODE_24_LTS", label: "Node.js 24 (LTS)" },
66
+ ],
67
+ python: [
68
+ { value: "PYTHON_312", label: "Python 3.12" },
69
+ { value: "PYTHON_310", label: "Python 3.10" },
70
+ { value: "PYTHON_311", label: "Python 3.11" },
71
+ { value: "PYTHON_313", label: "Python 3.13" },
72
+ { value: "PYTHON_314", label: "Python 3.14" },
73
+ ],
74
+ dotnet: [
75
+ { value: "DOTNET_8", label: ".NET 8" },
76
+ { value: "DOTNET_9", label: ".NET 9" },
77
+ { value: "DOTNET_10", label: ".NET 10" },
78
+ ],
57
79
  } as const;
58
80
 
81
+ type Runtime = keyof typeof RUNTIME_VERSIONS;
82
+
83
+ /**
84
+ * The platform's name for a runtime version a user typed the short way: `22` or `v22` for Node.js, `3.12` for
85
+ * Python, `8` or `8.0` for .NET. A value that is not recognised is kept as it is, and `check` names the allowed ones.
86
+ */
87
+ function runtimeVersionSlug(runtime: Runtime, value: string): string {
88
+ const upper = value.trim().toUpperCase();
89
+ const known = RUNTIME_VERSIONS[runtime].map(
90
+ (choice) => choice.value as string,
91
+ );
92
+ if (known.includes(upper)) return upper;
93
+ const digits = value
94
+ .trim()
95
+ .toLowerCase()
96
+ .replace(/^(v|python|\.net|dotnet|node(js)?)[ _-]?/, "")
97
+ .replace(/(\.x|_lts| lts)$/, "");
98
+ const candidate =
99
+ runtime === "nodejs"
100
+ ? `NODE_${digits.split(".")[0]}_LTS`
101
+ : runtime === "python"
102
+ ? `PYTHON_${digits.replace(".", "")}`
103
+ : `DOTNET_${digits.split(".")[0]}`;
104
+ return known.includes(candidate) ? candidate : value;
105
+ }
106
+
59
107
  /** What users read first (decision 28); the technical name follows in brackets. */
60
108
  export const SERVICE_LABELS: Record<ServiceName, string> = {
61
109
  portal: "Developer website",
@@ -174,7 +222,10 @@ paths:
174
222
  description: The service is up.
175
223
  `;
176
224
 
177
- const PLAN_TEMPLATES: Record<"free" | "tiered" | "none", unknown[]> = {
225
+ const PLAN_TEMPLATES: Record<
226
+ "free" | "tiered" | "starter" | "none",
227
+ unknown[]
228
+ > = {
178
229
  none: [],
179
230
  free: [
180
231
  {
@@ -188,6 +239,31 @@ const PLAN_TEMPLATES: Record<"free" | "tiered" | "none", unknown[]> = {
188
239
  },
189
240
  },
190
241
  ],
242
+ // Sample plans for the starter: a free plan and two with a price on request, so they show on the pricing page and
243
+ // the home page and still deploy on a Free project (a plan with a price needs a paid platform plan and billing).
244
+ starter: [
245
+ {
246
+ sku: "free",
247
+ displayName: "Free",
248
+ default: true,
249
+ limits: {
250
+ rateLimit: { calls: 3, periodSeconds: 60 },
251
+ quota: { calls: 1000, periodDays: 30 },
252
+ },
253
+ },
254
+ {
255
+ sku: "pro",
256
+ displayName: "Pro",
257
+ pricingModel: "custom",
258
+ priceLabel: "Contact us",
259
+ },
260
+ {
261
+ sku: "enterprise",
262
+ displayName: "Enterprise",
263
+ pricingModel: "custom",
264
+ priceLabel: "Contact us",
265
+ },
266
+ ],
191
267
  tiered: [
192
268
  {
193
269
  sku: "free",
@@ -284,6 +360,7 @@ async function buildPortal(
284
360
  const name = o.name?.trim() || resolved.project.name;
285
361
 
286
362
  let content: string[] = [];
363
+ let landing: string | undefined;
287
364
  if (!external) {
288
365
  if (Array.isArray(o.docs) && o.docs.length > 0) {
289
366
  content = await docsFromPaths(dir, o.docs);
@@ -320,7 +397,31 @@ async function buildPortal(
320
397
  const pages = path.join(resolved.root, "portal", "pages");
321
398
  if (template === "blank")
322
399
  await addFile(context, path.join(pages, ".gitkeep"), "");
323
- else
400
+ else if (options.template === "starter") {
401
+ // The starter has a full home page of its own, so the docs start at /introduction, where the page links to.
402
+ await addFile(
403
+ context,
404
+ path.join(pages, "introduction.mdx"),
405
+ starterPage(name),
406
+ );
407
+ const landingFile = path.join(
408
+ resolved.root,
409
+ "portal",
410
+ "landing",
411
+ "LandingPage.tsx",
412
+ );
413
+ await addFile(
414
+ context,
415
+ landingFile,
416
+ starterLandingPage({
417
+ name,
418
+ withPlans:
419
+ context.config.plans !== undefined &&
420
+ context.config.plans.enabled !== false,
421
+ }),
422
+ );
423
+ landing = rel(dir, landingFile);
424
+ } else
324
425
  await addFile(
325
426
  context,
326
427
  path.join(pages, "index.mdx"),
@@ -337,12 +438,31 @@ async function buildPortal(
337
438
  : content.length > 0
338
439
  ? { docs: { content } }
339
440
  : {}),
441
+ ...(landing ? { landing } : {}),
340
442
  ...(o.noConsole ? { console: { enabled: false } } : {}),
341
443
  ...(o.requireAuth ? { requireAuth: true } : {}),
342
444
  ...(o.search && !external ? { search: o.search } : {}),
343
445
  };
344
446
  }
345
447
 
448
+ /**
449
+ * A managed gateway runs the code in its source folder, and a new project has none. The folder is made (a `.gitkeep`
450
+ * keeps it in git) and the user is told it is empty. A folder that has files is left as it is.
451
+ */
452
+ async function createSourceFolder(
453
+ context: Context,
454
+ sourcePath: string,
455
+ ): Promise<void> {
456
+ const folder = path.join(context.dir, sourcePath);
457
+ const entries = await readdir(folder).catch(() => [] as string[]);
458
+ if (entries.some((name) => name !== ".gitkeep")) return;
459
+ if (entries.length === 0)
460
+ await addFile(context, path.join(folder, ".gitkeep"), "");
461
+ context.notes.push(
462
+ `The folder ${sourcePath}/ is empty: put your backend code there before you deploy.`,
463
+ );
464
+ }
465
+
346
466
  async function buildGateway(
347
467
  context: Context,
348
468
  ): Promise<NonNullable<ConfigV2["gateway"]>> {
@@ -401,25 +521,35 @@ async function buildGateway(
401
521
  ])
402
522
  : undefined);
403
523
  const runtimeVersion =
404
- o.runtimeVersion ??
405
- (ask && runtime
406
- ? await ask.text("Runtime version", {
407
- default: DEFAULT_RUNTIME_VERSION[runtime],
408
- })
409
- : undefined);
524
+ o.runtimeVersion !== undefined && runtime
525
+ ? runtimeVersionSlug(runtime, o.runtimeVersion)
526
+ : ask && runtime
527
+ ? await ask.choose(
528
+ "Runtime version",
529
+ RUNTIME_VERSIONS[runtime].map((choice, index) => ({
530
+ value: choice.value as string,
531
+ label:
532
+ index === 0 ? `${choice.label} (suggested)` : choice.label,
533
+ })),
534
+ )
535
+ : undefined;
410
536
  if (!source || !runtime || !runtimeVersion) {
411
537
  throw new ServiceEditError(
412
538
  "--origin managed needs --source, --runtime and --runtime-version.",
413
- "For example: --source server --runtime nodejs --runtime-version NODE_22_LTS.",
539
+ runtime
540
+ ? `For ${runtime}, --runtime-version is one of: ${RUNTIME_VERSIONS[runtime].map((choice) => choice.value).join(", ")} (a short form such as ${runtime === "nodejs" ? "22" : runtime === "python" ? "3.12" : "8"} works too).`
541
+ : "For example: --source server --runtime nodejs --runtime-version NODE_22_LTS.",
414
542
  );
415
543
  }
544
+ const sourcePath = insideProject(dir, source, "Source folder");
416
545
  origin = {
417
546
  mode,
418
- source: insideProject(dir, source, "Source folder"),
547
+ source: sourcePath,
419
548
  runtime,
420
549
  runtimeVersion,
421
550
  ...(o.exclude?.length ? { exclude: o.exclude } : {}),
422
551
  };
552
+ await createSourceFolder(context, sourcePath);
423
553
  }
424
554
 
425
555
  let specs: string[];
@@ -577,7 +707,7 @@ async function buildPlans(
577
707
  (options.template === "blank"
578
708
  ? "none"
579
709
  : options.template === "starter"
580
- ? "free"
710
+ ? "starter"
581
711
  : "free");
582
712
  // Only paid plans need a billing provider. A catalog of free plans takes no payment, so its default is `none` and the
583
713
  // config checks clean right away; the tiered template (paid plans) defaults to Stripe, whose key is set with `secrets set`.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The home page of the starter portal: hero, features, steps, an API showcase, the plans and a closing call to action,
3
+ * built from the sections of `@lukoweb/apitogo-module-landing`. The project owns the file and edits it freely.
4
+ */
5
+ export function starterLandingPage(options: {
6
+ name: string;
7
+ /** The plans service is on, so the page can show the plans (they come from the same catalog as the pricing page). */
8
+ withPlans: boolean;
9
+ }): string {
10
+ const name = JSON.stringify(options.name);
11
+ const plansImport = options.withPlans
12
+ ? 'import { usePlansPreview } from "@lukoweb/apitogo-plugin-dev-portal-billing";\n'
13
+ : "";
14
+ const plansHook = options.withPlans
15
+ ? " // The plans of the project's catalog: the same ones the pricing page shows.\n const plans = usePlansPreview();\n\n"
16
+ : "";
17
+ const plansSection = options.withPlans
18
+ ? " <PlansPreviewSection plans={plans} />\n"
19
+ : "";
20
+ return `import {
21
+ ApiShowcaseSection,
22
+ FeaturesSection,
23
+ HeroSection,
24
+ LandingShell,
25
+ ${options.withPlans ? "PlansPreviewSection,\n " : ""}PricingCtaSection,
26
+ StepsSection,
27
+ } from "@lukoweb/apitogo-module-landing";
28
+ ${plansImport}
29
+ const NAME = ${name};
30
+
31
+ export default function LandingPage() {
32
+ ${plansHook} return (
33
+ <LandingShell
34
+ title={NAME}
35
+ description="Documentation, an API reference, sign-in, API keys and plans for your API, in one place."
36
+ >
37
+ <HeroSection
38
+ hero={{
39
+ badge: "API PORTAL",
40
+ title: NAME,
41
+ description:
42
+ "Everything your developers need to use your API: documentation, an interactive reference, sign-in, API keys and plans.",
43
+ }}
44
+ primaryAction={{ label: "Get started", href: "/signup" }}
45
+ secondaryAction={{ label: "Read the docs", href: "/introduction" }}
46
+ codeExamples={[
47
+ {
48
+ filename: "Call the API",
49
+ language: "bash",
50
+ code: \`$ curl https://your-api.example.com/health \\\\
51
+ -H "x-api-key: YOUR_API_KEY"
52
+ # { "status": "ok" }\`,
53
+ },
54
+ ]}
55
+ />
56
+ <FeaturesSection
57
+ section={{
58
+ label: "What you get",
59
+ title: "A developer portal that is ready on day one",
60
+ description:
61
+ "Edit this page in apitogo/portal/landing/LandingPage.tsx to make it yours.",
62
+ }}
63
+ features={[
64
+ {
65
+ title: "Documentation",
66
+ description:
67
+ "Write pages in Markdown or MDX and they appear in the portal with a sidebar and search.",
68
+ icon: "book-open",
69
+ },
70
+ {
71
+ title: "API reference",
72
+ description:
73
+ "An interactive reference built from your OpenAPI file, with a try-it console.",
74
+ icon: "code",
75
+ },
76
+ {
77
+ title: "API keys",
78
+ description:
79
+ "People sign up, pick a plan and create keys themselves, with no work for you.",
80
+ icon: "shield-check",
81
+ },
82
+ {
83
+ title: "Plans and limits",
84
+ description:
85
+ "Rate limits and monthly quotas per plan, enforced at the gateway.",
86
+ icon: "gauge",
87
+ },
88
+ {
89
+ title: "Usage",
90
+ description:
91
+ "Each developer sees their own request counts and how much of the plan is left.",
92
+ icon: "activity",
93
+ },
94
+ {
95
+ title: "Sign-in",
96
+ description:
97
+ "Accounts and login pages that carry your name and theme.",
98
+ icon: "boxes",
99
+ },
100
+ ]}
101
+ />
102
+ <StepsSection
103
+ steps={[
104
+ {
105
+ step: "01",
106
+ title: "Create an account",
107
+ description: "Sign up in a minute, no approval queue.",
108
+ },
109
+ {
110
+ step: "02",
111
+ title: "Pick a plan",
112
+ description: "Start on the free plan and move up when you need more.",
113
+ },
114
+ {
115
+ step: "03",
116
+ title: "Call the API",
117
+ description: "Create a key and make your first request.",
118
+ },
119
+ ]}
120
+ />
121
+ <ApiShowcaseSection
122
+ apiShowcase={{
123
+ title: "Sample endpoints",
124
+ subtitle: "A slice of what is available. The full list is in the API reference.",
125
+ browseAllHref: "/api",
126
+ browseAllLabel: "Browse all APIs",
127
+ layout: "list",
128
+ items: [
129
+ {
130
+ name: "Health check",
131
+ path: "/health",
132
+ method: "GET",
133
+ summary: "Tells you the service is up.",
134
+ },
135
+ ],
136
+ }}
137
+ />
138
+ ${plansSection} <PricingCtaSection
139
+ variant="banner"
140
+ pricingCta={{
141
+ title: "Start building today",
142
+ description: "Create an account and get your API key in minutes.",
143
+ primaryAction: { label: "Get started", href: "/signup" },
144
+ }}
145
+ />
146
+ </LandingShell>
147
+ );
148
+ }
149
+ `;
150
+ }
@@ -6,7 +6,12 @@ import {
6
6
  ReferenceSpecError,
7
7
  renderReferenceSpec,
8
8
  } from "./gateway-reference.js";
9
- import { docRoutes, type HomeRedirect, homeRedirect } from "./home-redirect.js";
9
+ import {
10
+ docRoutes,
11
+ type HomeRedirect,
12
+ homeRedirect,
13
+ pickLandingRoute,
14
+ } from "./home-redirect.js";
10
15
  import {
11
16
  hasCapability,
12
17
  missingManifestServices,
@@ -234,11 +239,11 @@ export function planPortal(
234
239
  keysApiBaseUrl: plans.keysApiBaseUrl,
235
240
  billing: plans.billing,
236
241
  links: {
237
- ...(profile.modules.docs
238
- ? { docs: "/introduction" }
239
- : profile.docs.url
240
- ? { docs: profile.docs.url }
241
- : {}),
242
+ // Hosted docs are linked once their pages are known (see `buildPortalWorkspace`): there is no page to
243
+ // name before that, and a guessed one was a 404 on every portal whose first page is not called `introduction`.
244
+ ...(!profile.modules.docs && profile.docs.url
245
+ ? { docs: profile.docs.url }
246
+ : {}),
242
247
  ...(plan.apiReference ? { api: `/${API_REFERENCE_PATH}` } : {}),
243
248
  },
244
249
  };
@@ -296,6 +301,17 @@ export function buildSiteConfig(
296
301
  },
297
302
  modules: {
298
303
  docs: { enabled: plan.docs },
304
+ // A custom home page (`portal.landing`) is served at `/`; the build imports the component from its absolute path.
305
+ ...(portal?.landing
306
+ ? {
307
+ landing: {
308
+ enabled: true,
309
+ path: "/",
310
+ layout: "landing",
311
+ page: portal.landing.split(path.sep).join("/"),
312
+ },
313
+ }
314
+ : {}),
299
315
  // The plans pages (pricing, choosing a plan) exist only when billing is on, and the plans service is what makes it so.
300
316
  // Switching billing on also makes the user panel register its own placeholder plans page, at `/account/<path>`.
301
317
  // Its default path is `plans`, which is where the billing plugin puts the real page (subscribe, switch, cancel),
@@ -330,6 +346,16 @@ export function buildSiteConfig(
330
346
  }
331
347
  : {}),
332
348
  ...(navigation.length > 0 ? { navigation } : {}),
349
+ // The sidebar link above is only seen inside the docs; the header is what every page shows.
350
+ ...(plan.apiReference
351
+ ? {
352
+ header: {
353
+ navigation: [
354
+ { label: "API Reference", to: `/${API_REFERENCE_PATH}` },
355
+ ],
356
+ },
357
+ }
358
+ : {}),
333
359
  ...(redirects.length > 0 ? { redirects } : {}),
334
360
  ...(extras.protectedRoutes?.length
335
361
  ? { protectedRoutes: extras.protectedRoutes }
@@ -508,17 +534,32 @@ export async function preparePortal(
508
534
  includeDrafts: mode === "development",
509
535
  })
510
536
  : [];
511
- const home = homeRedirect({
512
- hostsDocs: plan.docs,
513
- docRoutes: hostedDocRoutes,
514
- externalDocsUrl: profile.docs.url,
515
- apiReferencePath: plan.apiReference ? `/${API_REFERENCE_PATH}` : undefined,
516
- userPanel: plan.userPanel,
517
- pricingPath:
518
- plan.plansPlugin && hasCapability(effective.services.plans, "catalog")
519
- ? CONSOLE_ROUTES.pricing
520
- : undefined,
521
- });
537
+ // The account pages' "Documentation" link goes to a page the portal really has, or is left out.
538
+ if (plan.plansPlugin && plan.docs) {
539
+ const landing = hostedDocRoutes.some(
540
+ (route) => route === "/" || route === "/index",
541
+ )
542
+ ? "/"
543
+ : pickLandingRoute(hostedDocRoutes);
544
+ if (landing)
545
+ plan.plansPlugin.links = { ...plan.plansPlugin.links, docs: landing };
546
+ }
547
+ // A portal with its own home page needs no redirect from `/`.
548
+ const home = resolved.portal?.landing
549
+ ? undefined
550
+ : homeRedirect({
551
+ hostsDocs: plan.docs,
552
+ docRoutes: hostedDocRoutes,
553
+ externalDocsUrl: profile.docs.url,
554
+ apiReferencePath: plan.apiReference
555
+ ? `/${API_REFERENCE_PATH}`
556
+ : undefined,
557
+ userPanel: plan.userPanel,
558
+ pricingPath:
559
+ plan.plansPlugin && hasCapability(effective.services.plans, "catalog")
560
+ ? CONSOLE_ROUTES.pricing
561
+ : undefined,
562
+ });
522
563
  const siteConfig = buildSiteConfig(
523
564
  resolved,
524
565
  plan,