@lukoweb/apitogo 0.2.53 → 0.2.56

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.
@@ -115,6 +115,11 @@ var ProtectedRoutesSchema = z.pipe(
115
115
 
116
116
  // src/vite/prerender/worker.ts
117
117
  var { template, distDir, serverConfigPath, entryServerPath, writeRedirects } = Piscina.workerData;
118
+ var isStaticNavigateWarning = (args) => typeof args[0] === "string" && args[0].includes("<Navigate> must not be used on the initial render");
119
+ var warn = console.warn.bind(console);
120
+ console.warn = (...args) => {
121
+ if (!isStaticNavigateWarning(args)) warn(...args);
122
+ };
118
123
  var server = await import(entryServerPath);
119
124
  var rawConfig = await import(serverConfigPath).then(
120
125
  (m) => m.default
@@ -186,5 +191,6 @@ var renderPage = async ({ urlPath }) => {
186
191
  };
187
192
  var worker_default = renderPage;
188
193
  export {
189
- worker_default as default
194
+ worker_default as default,
195
+ isStaticNavigateWarning
190
196
  };
@@ -0,0 +1,3 @@
1
+ import { type ChildProcess, type SpawnOptions } from "node:child_process";
2
+ export declare function quoteForWindowsShell(arg: string): string;
3
+ export declare function spawnCommand(command: string, args: readonly string[], options?: SpawnOptions): ChildProcess;
@@ -246,6 +246,27 @@ export interface DevPortalEmailDnsRecordDto {
246
246
  value: string;
247
247
  ttlSeconds?: number | null;
248
248
  }
249
+ export interface PlatformPlanDto {
250
+ id: string;
251
+ displayName: string;
252
+ tagline: string;
253
+ priceAmount: string;
254
+ pricePeriod: string;
255
+ ctaLabel: string;
256
+ sortOrder: number;
257
+ highlights: string[];
258
+ entitlements: {
259
+ featureKey: string;
260
+ valueType: string;
261
+ value: string;
262
+ }[];
263
+ }
264
+ export interface BillingWebhookStatusDto {
265
+ webhookSecretConfigured: boolean;
266
+ lastEventAtUtc: string | null;
267
+ lastEventType: string | null;
268
+ eventCount: number;
269
+ }
249
270
  export interface DevPortalBackendDto {
250
271
  fqdn: string | null;
251
272
  containerAppName: string;
@@ -573,6 +594,8 @@ export declare class PlatformApiClient {
573
594
  cancelUrl?: string;
574
595
  }): Promise<DevPortalBillingDto>;
575
596
  linkDevPortalIdentity(projectId: string): Promise<DevPortalIdentityLinkDto>;
597
+ listPlatformPlans(): Promise<PlatformPlanDto[]>;
598
+ getBillingWebhookStatus(projectId: string): Promise<BillingWebhookStatusDto | null>;
576
599
  getDevPortalBackend(projectId: string): Promise<DevPortalBackendDto | null>;
577
600
  getDevPortalSetupStatus(projectId: string): Promise<DevPortalSetupStatusDto>;
578
601
  getDevPortalSettings(projectId: string): Promise<DevPortalSettingsDto | null>;
@@ -1,3 +1,4 @@
1
+ import type { PlatformPlanDto } from "../client/platform-api.js";
1
2
  import type { FreeProjectsQuota } from "./free-projects-quota.js";
2
3
  export type PlatformPlanId = "free" | "starter" | "professional";
3
4
  export interface PlatformPlanOption {
@@ -12,6 +13,11 @@ export interface PlatformPlanOption {
12
13
  limitations: string[];
13
14
  }
14
15
  export declare const PLATFORM_PLAN_CATALOG: PlatformPlanOption[];
16
+ export type PlatformPlanDisplay = Pick<PlatformPlanOption, "displayName" | "priceLabel" | "summary" | "features" | "limitations"> & {
17
+ id: string;
18
+ };
19
+ export declare function toPlanDisplays(plans: readonly PlatformPlanDto[]): PlatformPlanDisplay[];
20
+ export declare function loadPlanDisplays(listPlatformPlans: () => Promise<PlatformPlanDto[]>): Promise<PlatformPlanDisplay[]>;
15
21
  export declare const PLATFORM_PLAN_USER_PROMPT = "Which API ToGo platform plan do you want for this project \u2014 Free, Starter, or Professional?";
16
22
  export declare function formatFreeProjectsQuotaHint(quota: FreeProjectsQuota): string;
17
23
  export declare function buildPlatformPlanSelectionAgentInstruction(quota?: FreeProjectsQuota | null): string;
@@ -42,7 +42,10 @@ export type ConsoleProgressReporter = ProgressReporter & {
42
42
  stop(): void;
43
43
  };
44
44
  export declare function friendlyModuleNames(text: string): string;
45
- export declare function createConsoleProgressReporter(stream?: NodeJS.WritableStream): ConsoleProgressReporter;
45
+ export declare function isInfrastructureMessage(message: string): boolean;
46
+ export declare function createConsoleProgressReporter(stream?: NodeJS.WritableStream, options?: {
47
+ verbose?: boolean;
48
+ }): ConsoleProgressReporter;
46
49
  export interface PollableJobValue {
47
50
  status: string;
48
51
  messages?: JobLogMessage[];
@@ -5,6 +5,7 @@ type SiteTitleProps = {
5
5
  export declare const splitBrandWordmark: (title: string) => {
6
6
  prefix: string;
7
7
  accent: string | null;
8
+ spaced: boolean;
8
9
  };
9
10
  export declare const SiteTitle: ({ title, className }: SiteTitleProps) => import("react/jsx-runtime").JSX.Element;
10
11
  export {};
@@ -49,7 +49,7 @@ npx apitogo add plans --template free
49
49
  `--create-spec` writes a small OpenAPI file with one `GET /health` operation, so the gateway has
50
50
  something to expose. For a real API, point `gateway.specs` at your own OpenAPI file instead.
51
51
 
52
- The Free template in `plans.json` allows 3 calls per minute and 1000 calls per 30 days. Change the
52
+ The Free template in `plans.json` allows 60 calls per minute and 1000 calls per 30 days. Change the
53
53
  numbers in `plans.json` and deploy again to change the limits.
54
54
 
55
55
  With plans in the project, customers call the gateway with their API key alone. Their login token is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo",
3
- "version": "0.2.53",
3
+ "version": "0.2.56",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -1,4 +1,5 @@
1
1
  import type { InputNavigationItem } from "../validators/InputNavigationSchema.js";
2
+ import { comparePages, type PageOrders } from "./page-order.js";
2
3
 
3
4
  /** Label of the one header tab that holds the sidebar made from the docs pages. */
4
5
  export const DOCS_NAVIGATION_LABEL = "Documentation";
@@ -19,9 +20,14 @@ export function folderLabel(name: string): string {
19
20
 
20
21
  const byName = (a: string, b: string) => a.localeCompare(b);
21
22
 
22
- const toItems = (branch: Branch, prefix: string): InputNavigationItem[] => {
23
+ const toItems = (
24
+ branch: Branch,
25
+ prefix: string,
26
+ orders: PageOrders,
27
+ ): InputNavigationItem[] => {
28
+ // Index first, then the pages' own order, then the usual first pages, then by name (API-125).
23
29
  const names = [...branch.pages.keys()].sort((a, b) =>
24
- a === "index" ? -1 : b === "index" ? 1 : byName(a, b),
30
+ comparePages(`/${prefix}${a}`, `/${prefix}${b}`, orders),
25
31
  );
26
32
  const folders = [...branch.folders.keys()].sort(byName);
27
33
  const folderPath = prefix === "" ? "/" : `/${prefix.slice(0, -1)}`;
@@ -51,6 +57,7 @@ const toItems = (branch: Branch, prefix: string): InputNavigationItem[] => {
51
57
  items: toItems(
52
58
  branch.folders.get(folder) ?? emptyBranch(),
53
59
  `${prefix}${folder}/`,
60
+ orders,
54
61
  ),
55
62
  }),
56
63
  ),
@@ -77,6 +84,7 @@ const branchOf = (root: Branch, route: string) => {
77
84
  export function docsNavigation(
78
85
  routes: string[],
79
86
  customPages: readonly CustomDocsPage[] = [],
87
+ orders: PageOrders = new Map(),
80
88
  ): InputNavigationItem[] {
81
89
  const root = emptyBranch();
82
90
  for (const route of routes) {
@@ -88,7 +96,7 @@ export function docsNavigation(
88
96
  if (page && !branch.pages.has(page))
89
97
  branch.pages.set(page, { kind: "custom", ...custom });
90
98
  }
91
- const items = toItems(root, "");
99
+ const items = toItems(root, "", orders);
92
100
  return items.length === 0
93
101
  ? []
94
102
  : [{ type: "category", label: DOCS_NAVIGATION_LABEL, items }];
@@ -49,7 +49,7 @@ const items = [
49
49
  { id: "3", name: "Third item" },
50
50
  ];
51
51
 
52
- // Lets the portal's API page call this server while you run it locally (\`apitogo dev\`). Once deployed, the gateway
52
+ // Lets the portal's API page call this server while you run it locally (\`apitogo run\`). Once deployed, the gateway
53
53
  // answers browsers itself and keeps its own list of allowed sites.
54
54
  const cors = {
55
55
  "access-control-allow-origin": "*",
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { glob } from "glob";
3
3
  import globParent from "glob-parent";
4
4
  import { readFrontmatter } from "../../lib/util/readFrontmatter.js";
5
+ import { firstPage, orderValue, type PageOrders } from "./page-order.js";
5
6
  import { toGlobPath } from "./resolve.js";
6
7
 
7
8
  export type HomeRedirect = { from: string; to: string };
@@ -57,11 +58,41 @@ export async function docRoutes(
57
58
  }
58
59
 
59
60
  /** The page a visitor lands on when the site has no home page: the top-most page, then the first by name. */
60
- export function pickLandingRoute(routes: string[]): string | undefined {
61
- const depth = (route: string) => route.split("/").length;
62
- return [...routes].sort(
63
- (a, b) => depth(a) - depth(b) || a.localeCompare(b),
64
- )[0];
61
+ export function pickLandingRoute(
62
+ routes: string[],
63
+ orders?: PageOrders,
64
+ ): string | undefined {
65
+ return firstPage(routes, orders);
66
+ }
67
+
68
+ /**
69
+ * The `order` each Markdown page sets in its frontmatter, by route (API-125). Pages without one are not in the map.
70
+ */
71
+ export async function docPageOrders(
72
+ patterns: string[],
73
+ ): Promise<Map<string, number>> {
74
+ const globs = patterns.map(toGlobPath);
75
+ const excluded = globs
76
+ .filter((pattern) => pattern.startsWith("!"))
77
+ .map((pattern) => pattern.slice(1));
78
+ const orders = new Map<string, number>();
79
+ for (const pattern of globs.filter((p) => !p.startsWith("!"))) {
80
+ const parent = globParent(pattern);
81
+ const files = await glob(pattern, {
82
+ absolute: true,
83
+ posix: true,
84
+ ignore: [...IGNORED, ...excluded],
85
+ });
86
+ for (const raw of files) {
87
+ const order = orderValue((await readFrontmatter(raw)).data.order);
88
+ if (order === undefined) continue;
89
+ const route = `/${path.posix
90
+ .relative(parent, raw.replace(/^\/\/\?\//, ""))
91
+ .replace(/\.mdx?$/, "")}`;
92
+ if (!orders.has(route)) orders.set(route, order);
93
+ }
94
+ }
95
+ return orders;
65
96
  }
66
97
 
67
98
  export type HomeRedirectInput = {
@@ -77,6 +108,8 @@ export type HomeRedirectInput = {
77
108
  userPanel: boolean;
78
109
  /** The portal has a pricing page, at this path. */
79
110
  pricingPath?: string;
111
+ /** The pages' own order (API-125), so `/` opens the first page in the sidebar's order. */
112
+ pageOrders?: PageOrders;
80
113
  };
81
114
 
82
115
  /**
@@ -96,7 +129,7 @@ export function homeRedirect(
96
129
  if (input.hostsDocs) {
97
130
  if (input.docRoutes.some((route) => route === "/" || route === "/index"))
98
131
  return undefined;
99
- const landing = pickLandingRoute(input.docRoutes);
132
+ const landing = pickLandingRoute(input.docRoutes, input.pageOrders);
100
133
  if (landing) return { from: "/", to: landing };
101
134
  return input.apiReferencePath
102
135
  ? { from: "/", to: input.apiReferencePath }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The order of docs pages (API-125). A page may set it (`order: 2` in the frontmatter of a Markdown page,
3
+ * `export const order = 2` in a React page). Pages with an order come first, by that number; the others follow, the
4
+ * usual first pages (introduction, getting started, ...) before the rest, then by name. A folder's index page is
5
+ * always its first page. Before this the order was by name only, so "Changelog" came first and the header's
6
+ * "Documentation" link opened it.
7
+ */
8
+ export type PageOrders = ReadonlyMap<string, number>;
9
+
10
+ /** Names that usually open a set of docs, best first. */
11
+ const FIRST_PAGE_NAMES = [
12
+ "introduction",
13
+ "intro",
14
+ "welcome",
15
+ "getting-started",
16
+ "get-started",
17
+ "overview",
18
+ "quickstart",
19
+ "quick-start",
20
+ ];
21
+
22
+ const lastSegment = (route: string) =>
23
+ route.split("/").filter(Boolean).pop() ?? "";
24
+
25
+ const rank = (route: string, orders: PageOrders) => {
26
+ const name = lastSegment(route).toLowerCase();
27
+ if (name === "index" || route === "/") return [0, 0] as const;
28
+ const order = orders.get(route);
29
+ if (order !== undefined) return [1, order] as const;
30
+ const preferred = FIRST_PAGE_NAMES.indexOf(name);
31
+ return [2, preferred === -1 ? FIRST_PAGE_NAMES.length : preferred] as const;
32
+ };
33
+
34
+ /** Sorts sibling pages: index, then by `order`, then the usual first pages, then by name. */
35
+ export function comparePages(
36
+ a: string,
37
+ b: string,
38
+ orders: PageOrders = new Map(),
39
+ ): number {
40
+ const [groupA, valueA] = rank(a, orders);
41
+ const [groupB, valueB] = rank(b, orders);
42
+ return groupA - groupB || valueA - valueB || a.localeCompare(b);
43
+ }
44
+
45
+ /** The page a visitor lands on: the top-most page, then the first in page order. */
46
+ export function firstPage(
47
+ routes: readonly string[],
48
+ orders: PageOrders = new Map(),
49
+ ): string | undefined {
50
+ const depth = (route: string) => route.split("/").length;
51
+ return [...routes].sort(
52
+ (a, b) => depth(a) - depth(b) || comparePages(a, b, orders),
53
+ )[0];
54
+ }
55
+
56
+ /** `order: 3` (or `order: "3"`) from a page's frontmatter; undefined when it is missing or not a whole number. */
57
+ export function orderValue(value: unknown): number | undefined {
58
+ const number =
59
+ typeof value === "number"
60
+ ? value
61
+ : typeof value === "string" && value.trim() !== ""
62
+ ? Number(value)
63
+ : Number.NaN;
64
+ return Number.isInteger(number) ? number : undefined;
65
+ }
66
+
67
+ const TSX_ORDER = /export\s+const\s+order\s*(?::\s*number\s*)?=\s*(-?\d+)/;
68
+
69
+ /** `export const order = 3` in a React page. */
70
+ export function tsxPageOrder(source: string): number | undefined {
71
+ const match = TSX_ORDER.exec(source);
72
+ return match?.[1] ? Number(match[1]) : undefined;
73
+ }
@@ -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
  },
@@ -686,6 +685,11 @@ async function buildGateway(
686
685
  : {}),
687
686
  ...(quota ? { quota: { calls: quota[0], periodDays: quota[1] } } : {}),
688
687
  ...(o.cors?.length ? { cors: o.cors } : {}),
688
+ // The sample server's health check needs no key: monitors and the platform call it, and it is not something a
689
+ // plan sells, so it stays out of the plans' feature lists (API-126).
690
+ ...(sampleServer
691
+ ? { operations: { public: [`GET ${FULL_SERVER_HEALTH_PATH}`] } }
692
+ : {}),
689
693
  };
690
694
 
691
695
  return {
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { glob } from "glob";
4
4
  import globParent from "glob-parent";
5
5
  import { folderLabel } from "./docs-navigation.js";
6
+ import { tsxPageOrder } from "./page-order.js";
6
7
  import { toGlobPath } from "./resolve.js";
7
8
 
8
9
  /**
@@ -18,6 +19,8 @@ export type TsxPage = {
18
19
  file: string;
19
20
  /** Sidebar label: `export const title = "..."` in the file, or the file name. */
20
21
  label: string;
22
+ /** `export const order = 2` in the file (API-125). */
23
+ order?: number;
21
24
  };
22
25
 
23
26
  const IGNORED = ["**/node_modules/**", "**/dist/**", "**/.git/**"];
@@ -90,7 +93,13 @@ export async function findTsxPages(
90
93
  const route = `/${relative.replace(/\.tsx$/, "")}`;
91
94
  if (found.has(route)) continue;
92
95
  const source = await readFile(file, "utf8").catch(() => "");
93
- found.set(route, { route, file, label: tsxPageLabel(source, file) });
96
+ const order = tsxPageOrder(source);
97
+ found.set(route, {
98
+ route,
99
+ file,
100
+ label: tsxPageLabel(source, file),
101
+ ...(order !== undefined ? { order } : {}),
102
+ });
94
103
  }
95
104
  }
96
105
  return [...found.values()].sort((a, b) => a.route.localeCompare(b.route));
@@ -10,6 +10,7 @@ import {
10
10
  docRoutes,
11
11
  type HomeRedirect,
12
12
  homeRedirect,
13
+ docPageOrders,
13
14
  pickLandingRoute,
14
15
  } from "./home-redirect.js";
15
16
  import {
@@ -93,6 +94,8 @@ export type PortalPlan = {
93
94
  keyHeader?: string;
94
95
  /** The gateway's address, for code samples on the portal's pages. */
95
96
  gatewayBaseUrl?: string;
97
+ /** The plan shown as "Popular": only the one the tenant marked `featured` in plans.json (API-124). */
98
+ featuredPlanSku?: string;
96
99
  };
97
100
  /** Account pages. */
98
101
  userPanel: boolean;
@@ -677,13 +680,32 @@ export async function preparePortal(
677
680
  const hostedDocRoutes = [
678
681
  ...new Set([...markdownRoutes, ...tsxPages.map((page) => page.route)]),
679
682
  ].sort();
683
+ // The pages' own order (API-125): the sidebar, `/` and the "Documentation" link follow it.
684
+ const pageOrders = plan.docs
685
+ ? await docPageOrders(profile.docs.content)
686
+ : new Map<string, number>();
687
+ for (const page of tsxPages)
688
+ if (page.order !== undefined && !pageOrders.has(page.route))
689
+ pageOrders.set(page.route, page.order);
690
+ // "Popular" is the tenant's choice, never guessed (API-124).
691
+ if (plan.plansPlugin) {
692
+ const featured = (await readDemoPlans(resolved.plans?.file))?.find(
693
+ (item): item is { sku: string } =>
694
+ typeof item === "object" &&
695
+ item !== null &&
696
+ (item as { featured?: unknown }).featured === true &&
697
+ typeof (item as { sku?: unknown }).sku === "string",
698
+ );
699
+ if (featured)
700
+ plan.plansPlugin.featuredPlanSku = featured.sku.trim().toLowerCase();
701
+ }
680
702
  // The account pages' "Documentation" link goes to a page the portal really has, or is left out.
681
703
  if (plan.plansPlugin && plan.docs) {
682
704
  const landing = hostedDocRoutes.some(
683
705
  (route) => route === "/" || route === "/index",
684
706
  )
685
707
  ? "/"
686
- : pickLandingRoute(hostedDocRoutes);
708
+ : pickLandingRoute(hostedDocRoutes, pageOrders);
687
709
  if (landing)
688
710
  plan.plansPlugin.links = { ...plan.plansPlugin.links, docs: landing };
689
711
  }
@@ -702,6 +724,7 @@ export async function preparePortal(
702
724
  plan.plansPlugin && hasCapability(effective.services.plans, "catalog")
703
725
  ? CONSOLE_ROUTES.pricing
704
726
  : undefined,
727
+ pageOrders,
705
728
  });
706
729
  const siteConfig = buildSiteConfig(
707
730
  resolved,
@@ -716,8 +739,9 @@ export async function preparePortal(
716
739
  label: page.label,
717
740
  element: wrapperPages[index]?.id ?? "",
718
741
  })),
742
+ pageOrders,
719
743
  ),
720
- docsPath: pickLandingRoute(hostedDocRoutes),
744
+ docsPath: pickLandingRoute(hostedDocRoutes, pageOrders),
721
745
  demoPlans:
722
746
  plan.demo && plan.plansPlugin
723
747
  ? await readDemoPlans(resolved.plans?.file)
@@ -358,6 +358,19 @@ export function DevPortalRegisterPage({
358
358
  .finally(() => setConfigLoading(false));
359
359
  }, [baseUrl]);
360
360
 
361
+ // With one sign-in provider and no native sign-up, the button was a needless extra step: the page goes to the
362
+ // provider at once, and the button stays as a fallback (API-126).
363
+ const onlyProvider =
364
+ config && !isNativeAuthEnabled(config) && config.providers.length === 1
365
+ ? config.providers[0]
366
+ : undefined;
367
+ useEffect(() => {
368
+ if (!baseUrl || !onlyProvider) return;
369
+ window.location.assign(
370
+ buildDevPortalOidcChallengeUrl(baseUrl, onlyProvider.id, returnUrl),
371
+ );
372
+ }, [baseUrl, onlyProvider, returnUrl]);
373
+
361
374
  const onSubmit = useCallback(
362
375
  async (event: React.FormEvent) => {
363
376
  event.preventDefault();
@@ -408,7 +421,11 @@ export function DevPortalRegisterPage({
408
421
  return (
409
422
  <AuthPageShell
410
423
  title="Create your account"
411
- description="Accounts are created on our secure sign-in provider."
424
+ description={
425
+ onlyProvider
426
+ ? "Opening the sign-up form…"
427
+ : "Accounts are created on our secure sign-in provider."
428
+ }
412
429
  >
413
430
  {config.providers.length ? (
414
431
  <OidcProviderButtons
@@ -1,58 +1,69 @@
1
- import { cn } from "../util/cn.js";
2
-
3
- type SiteTitleProps = {
4
- title: string;
5
- className?: string;
6
- };
7
-
8
- export const splitBrandWordmark = (title: string) => {
9
- const trimmed = title.trim();
10
- if (!trimmed) {
11
- return { prefix: title, accent: null as string | null };
12
- }
13
-
14
- const words = trimmed.split(/\s+/).filter(Boolean);
15
- if (words.length >= 2) {
16
- return {
17
- prefix: words.slice(0, -1).join(" "),
18
- accent: words.at(-1) ?? null,
19
- };
20
- }
21
-
22
- const word = words[0] ?? trimmed;
23
- const apiMatch = word.match(/^(.*)(API)$/i);
24
- if (apiMatch?.[1]) {
25
- return { prefix: apiMatch[1], accent: apiMatch[2] ?? "API" };
26
- }
27
-
28
- const camelMatch = word.match(/^(.*[a-z])([A-Z][a-z]*)$/);
29
- if (camelMatch?.[1]) {
30
- return { prefix: camelMatch[1], accent: camelMatch[2] ?? null };
31
- }
32
-
33
- if (word.length < 2) {
34
- return { prefix: word, accent: null };
35
- }
36
-
37
- const splitAt = Math.floor(word.length / 2);
38
- return { prefix: word.slice(0, splitAt), accent: word.slice(splitAt) };
39
- };
40
-
41
- export const SiteTitle = ({ title, className }: SiteTitleProps) => {
42
- const { prefix, accent } = splitBrandWordmark(title);
43
-
44
- return (
45
- <span
46
- className={cn(
47
- "font-sans text-[1.4rem] font-extrabold tracking-[-0.03em]",
48
- className,
49
- )}
50
- style={{ color: "var(--foreground)" }}
51
- >
52
- {prefix}
53
- {accent ? (
54
- <span style={{ color: "var(--primary)" }}>{accent}</span>
55
- ) : null}
56
- </span>
57
- );
58
- };
1
+ import { cn } from "../util/cn.js";
2
+
3
+ type SiteTitleProps = {
4
+ title: string;
5
+ className?: string;
6
+ };
7
+
8
+ export const splitBrandWordmark = (title: string) => {
9
+ const trimmed = title.trim();
10
+ if (!trimmed) {
11
+ return { prefix: title, accent: null as string | null, spaced: false };
12
+ }
13
+
14
+ // A name of several words keeps its space before the accent: "full portal", not "fullportal" (API-126).
15
+ const words = trimmed.split(/\s+/).filter(Boolean);
16
+ if (words.length >= 2) {
17
+ return {
18
+ prefix: words.slice(0, -1).join(" "),
19
+ accent: words.at(-1) ?? null,
20
+ spaced: true,
21
+ };
22
+ }
23
+
24
+ const word = words[0] ?? trimmed;
25
+ const apiMatch = word.match(/^(.*)(API)$/i);
26
+ if (apiMatch?.[1]) {
27
+ return { prefix: apiMatch[1], accent: apiMatch[2] ?? "API", spaced: false };
28
+ }
29
+
30
+ const camelMatch = word.match(/^(.*[a-z])([A-Z][a-z]*)$/);
31
+ if (camelMatch?.[1]) {
32
+ return {
33
+ prefix: camelMatch[1],
34
+ accent: camelMatch[2] ?? null,
35
+ spaced: false,
36
+ };
37
+ }
38
+
39
+ if (word.length < 2) {
40
+ return { prefix: word, accent: null, spaced: false };
41
+ }
42
+
43
+ const splitAt = Math.floor(word.length / 2);
44
+ return {
45
+ prefix: word.slice(0, splitAt),
46
+ accent: word.slice(splitAt),
47
+ spaced: false,
48
+ };
49
+ };
50
+
51
+ export const SiteTitle = ({ title, className }: SiteTitleProps) => {
52
+ const { prefix, accent, spaced } = splitBrandWordmark(title);
53
+
54
+ return (
55
+ <span
56
+ className={cn(
57
+ "font-sans text-[1.4rem] font-extrabold tracking-[-0.03em]",
58
+ className,
59
+ )}
60
+ style={{ color: "var(--foreground)" }}
61
+ >
62
+ {prefix}
63
+ {accent && spaced ? " " : null}
64
+ {accent ? (
65
+ <span style={{ color: "var(--primary)" }}>{accent}</span>
66
+ ) : null}
67
+ </span>
68
+ );
69
+ };