@lukoweb/apitogo 0.2.53 → 0.2.54

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;
@@ -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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo",
3
- "version": "0.2.53",
3
+ "version": "0.2.54",
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
+ }
@@ -686,6 +686,11 @@ async function buildGateway(
686
686
  : {}),
687
687
  ...(quota ? { quota: { calls: quota[0], periodDays: quota[1] } } : {}),
688
688
  ...(o.cors?.length ? { cors: o.cors } : {}),
689
+ // The sample server's health check needs no key: monitors and the platform call it, and it is not something a
690
+ // plan sells, so it stays out of the plans' feature lists (API-126).
691
+ ...(sampleServer
692
+ ? { operations: { public: [`GET ${FULL_SERVER_HEALTH_PATH}`] } }
693
+ : {}),
689
694
  };
690
695
 
691
696
  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 {
@@ -677,13 +678,20 @@ export async function preparePortal(
677
678
  const hostedDocRoutes = [
678
679
  ...new Set([...markdownRoutes, ...tsxPages.map((page) => page.route)]),
679
680
  ].sort();
681
+ // The pages' own order (API-125): the sidebar, `/` and the "Documentation" link follow it.
682
+ const pageOrders = plan.docs
683
+ ? await docPageOrders(profile.docs.content)
684
+ : new Map<string, number>();
685
+ for (const page of tsxPages)
686
+ if (page.order !== undefined && !pageOrders.has(page.route))
687
+ pageOrders.set(page.route, page.order);
680
688
  // The account pages' "Documentation" link goes to a page the portal really has, or is left out.
681
689
  if (plan.plansPlugin && plan.docs) {
682
690
  const landing = hostedDocRoutes.some(
683
691
  (route) => route === "/" || route === "/index",
684
692
  )
685
693
  ? "/"
686
- : pickLandingRoute(hostedDocRoutes);
694
+ : pickLandingRoute(hostedDocRoutes, pageOrders);
687
695
  if (landing)
688
696
  plan.plansPlugin.links = { ...plan.plansPlugin.links, docs: landing };
689
697
  }
@@ -702,6 +710,7 @@ export async function preparePortal(
702
710
  plan.plansPlugin && hasCapability(effective.services.plans, "catalog")
703
711
  ? CONSOLE_ROUTES.pricing
704
712
  : undefined,
713
+ pageOrders,
705
714
  });
706
715
  const siteConfig = buildSiteConfig(
707
716
  resolved,
@@ -716,8 +725,9 @@ export async function preparePortal(
716
725
  label: page.label,
717
726
  element: wrapperPages[index]?.id ?? "",
718
727
  })),
728
+ pageOrders,
719
729
  ),
720
- docsPath: pickLandingRoute(hostedDocRoutes),
730
+ docsPath: pickLandingRoute(hostedDocRoutes, pageOrders),
721
731
  demoPlans:
722
732
  plan.demo && plan.plansPlugin
723
733
  ? 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
+ };