@lukoweb/apitogo 0.2.49 → 0.2.51

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.
@@ -23,6 +23,7 @@ import {
23
23
  import { type PortalProfile, portalProfile } from "./portal-profile.js";
24
24
  import { protectedRoutesFor } from "./protected-routes.js";
25
25
  import type { ResolvedConfigV2 } from "./resolve.js";
26
+ import { findTsxPages, TSX_PAGE_ELEMENT_PREFIX } from "./tsx-pages.js";
26
27
 
27
28
  export const WRAPPER_FILE = "apitogo.config.tsx";
28
29
 
@@ -76,6 +77,10 @@ export type PortalPlan = {
76
77
  billing?: string;
77
78
  /** Where the account pages link to: only places this portal has (hosted docs, an external docs site, the API page). */
78
79
  links?: { docs?: string; api?: string };
80
+ /** The header API users send their key in, from the gateway entry of services.json (API-120). */
81
+ keyHeader?: string;
82
+ /** The gateway's address, for code samples on the portal's pages. */
83
+ gatewayBaseUrl?: string;
79
84
  };
80
85
  /** Account pages. */
81
86
  userPanel: boolean;
@@ -258,6 +263,10 @@ export function planPortal(
258
263
  apiBaseUrl: plans.apiBaseUrl,
259
264
  keysApiBaseUrl: plans.keysApiBaseUrl,
260
265
  billing: plans.billing,
266
+ ...(gateway?.keyHeaderName ? { keyHeader: gateway.keyHeaderName } : {}),
267
+ ...(gateway?.baseUrl && serviceReady(gateway)
268
+ ? { gatewayBaseUrl: gateway.baseUrl }
269
+ : {}),
261
270
  links: {
262
271
  // Hosted docs are linked once their pages are known (see `buildPortalWorkspace`): there is no page to
263
272
  // name before that, and a guessed one was a 404 on every portal whose first page is not called `introduction`.
@@ -424,8 +433,17 @@ function authenticationFor(plan: PortalPlan): Record<string, unknown> {
424
433
  export const PREVIEW_BANNER_MESSAGE =
425
434
  "Preview mode: sign in with demo@apitogo.local / password to try the console. Run `apitogo deploy` for the real services.";
426
435
 
427
- /** The wrapper source. Imports exist only for what the plan registers. */
428
- export function renderWrapper(plan: PortalPlan): string {
436
+ /** A React docs page the wrapper imports: `importPath` is relative to the wrapper, through the `content` link. */
437
+ export type WrapperPage = { id: string; importPath: string };
438
+
439
+ /**
440
+ * The wrapper source. Imports exist only for what the plan registers. React docs pages are imported here and put in
441
+ * place of their sidebar placeholders, since the site config is JSON and cannot hold a component.
442
+ */
443
+ export function renderWrapper(
444
+ plan: PortalPlan,
445
+ pages: readonly WrapperPage[] = [],
446
+ ): string {
429
447
  const imports = [
430
448
  'import { buildApitogoConfig } from "@lukoweb/apitogo/build-config";',
431
449
  ...(plan.plansPlugin
@@ -433,12 +451,41 @@ export function renderWrapper(plan: PortalPlan): string {
433
451
  'import { devPortalBillingPlugin } from "@lukoweb/apitogo-plugin-dev-portal-billing";',
434
452
  ]
435
453
  : []),
454
+ ...(pages.length > 0 ? ['import { createElement } from "react";'] : []),
455
+ ...pages.map(
456
+ (page, index) =>
457
+ `import TsxPage${index} from ${JSON.stringify(page.importPath)};`,
458
+ ),
436
459
  `import siteConfig from "./${SITE_CONFIG_FILE}";`,
437
460
  ];
438
461
  const plugins = plan.plansPlugin
439
462
  ? `[devPortalBillingPlugin(${JSON.stringify(plan.plansPlugin)})]`
440
463
  : "[]";
441
464
 
465
+ const pageLines =
466
+ pages.length === 0
467
+ ? []
468
+ : [
469
+ "",
470
+ "// React docs pages: each sidebar placeholder becomes the page's component.",
471
+ "const tsxPages: Record<string, any> = {",
472
+ ...pages.map(
473
+ (page, index) => ` ${JSON.stringify(page.id)}: TsxPage${index},`,
474
+ ),
475
+ "};",
476
+ "const withTsxPages = (items: any[]): any[] =>",
477
+ " items.map((item) => {",
478
+ ' if (item === null || typeof item !== "object") return item;',
479
+ " const page =",
480
+ ' typeof item.element === "string" ? tsxPages[item.element] : undefined;',
481
+ " return {",
482
+ " ...item,",
483
+ " ...(page ? { element: createElement(page) } : {}),",
484
+ " ...(Array.isArray(item.items) ? { items: withTsxPages(item.items) } : {}),",
485
+ " };",
486
+ " });",
487
+ ];
488
+
442
489
  return [
443
490
  "// Generated by apitogo from apitogo.config.json and services.json. Do not edit: it is rewritten on every build.",
444
491
  ...(plan.preview
@@ -447,11 +494,28 @@ export function renderWrapper(plan: PortalPlan): string {
447
494
  ]
448
495
  : []),
449
496
  ...imports,
497
+ ...pageLines,
450
498
  "",
451
- "export default buildApitogoConfig({",
452
- " ...siteConfig,",
453
- ` plugins: ${plugins},`,
454
- "});",
499
+ // The components go in after buildApitogoConfig: it copies the config with structuredClone, which cannot copy a
500
+ // React element.
501
+ ...(pages.length > 0
502
+ ? [
503
+ "const config = buildApitogoConfig({",
504
+ " ...siteConfig,",
505
+ ` plugins: ${plugins},`,
506
+ "});",
507
+ "",
508
+ "export default {",
509
+ " ...config,",
510
+ " navigation: withTsxPages((config as any).navigation ?? []),",
511
+ "};",
512
+ ]
513
+ : [
514
+ "export default buildApitogoConfig({",
515
+ " ...siteConfig,",
516
+ ` plugins: ${plugins},`,
517
+ "});",
518
+ ]),
455
519
  "",
456
520
  ].join("\n");
457
521
  }
@@ -580,11 +644,27 @@ export async function preparePortal(
580
644
  });
581
645
  }
582
646
  }
583
- const hostedDocRoutes = plan.docs
647
+ const markdownRoutes = plan.docs
584
648
  ? await docRoutes(profile.docs.content, {
585
649
  includeDrafts: mode === "development",
586
650
  })
587
651
  : [];
652
+ // React pages next to the Markdown ones. A Markdown page wins over a React page at the same address.
653
+ const tsxPages = plan.docs
654
+ ? (await findTsxPages(profile.docs.content)).filter(
655
+ (page) => !markdownRoutes.includes(page.route),
656
+ )
657
+ : [];
658
+ const wrapperPages: WrapperPage[] = tsxPages.map((page, index) => ({
659
+ id: `${TSX_PAGE_ELEMENT_PREFIX}${index}`,
660
+ importPath: `./${CONTENT_LINK}/${path
661
+ .relative(resolved.configDir, page.file)
662
+ .split(path.sep)
663
+ .join("/")}`,
664
+ }));
665
+ const hostedDocRoutes = [
666
+ ...new Set([...markdownRoutes, ...tsxPages.map((page) => page.route)]),
667
+ ].sort();
588
668
  // The account pages' "Documentation" link goes to a page the portal really has, or is left out.
589
669
  if (plan.plansPlugin && plan.docs) {
590
670
  const landing = hostedDocRoutes.some(
@@ -617,7 +697,14 @@ export async function preparePortal(
617
697
  await readTheme(resolved),
618
698
  {
619
699
  redirects: home ? [home] : [],
620
- docsNavigation: docsNavigation(hostedDocRoutes),
700
+ docsNavigation: docsNavigation(
701
+ markdownRoutes,
702
+ tsxPages.map((page, index) => ({
703
+ route: page.route,
704
+ label: page.label,
705
+ element: wrapperPages[index]?.id ?? "",
706
+ })),
707
+ ),
621
708
  docsPath: pickLandingRoute(hostedDocRoutes),
622
709
  demoPlans:
623
710
  plan.demo && plan.plansPlugin
@@ -633,7 +720,7 @@ export async function preparePortal(
633
720
  }),
634
721
  },
635
722
  );
636
- const wrapper = renderWrapper(plan);
723
+ const wrapper = renderWrapper(plan, wrapperPages);
637
724
  const wrapperPath = path.join(resolved.state.generatedDir, WRAPPER_FILE);
638
725
  if (options.write !== false) {
639
726
  await mkdir(path.dirname(wrapperPath), { recursive: true });
@@ -1,135 +1,147 @@
1
- import { HTTPSnippet } from "@zudoku/httpsnippet";
2
- import type { OperationsFragmentFragment } from "../graphql/graphql.js";
3
-
4
- const toFormDataParams = (text?: string) => {
5
- const stringify = (v: unknown) =>
6
- typeof v === "string" ? v : JSON.stringify(v);
7
-
8
- try {
9
- const obj = text && JSON.parse(text);
10
- if (typeof obj !== "object" || !obj) return [];
11
-
12
- return Object.entries(obj).flatMap(([name, value]) => {
13
- const values = Array.isArray(value) ? value : [value];
14
- return values.map((v) => ({ name, value: stringify(v) }));
15
- });
16
- } catch {
17
- return [];
18
- }
19
- };
20
-
21
- export const createHttpSnippet = ({
22
- operation,
23
- selectedServer,
24
- exampleBody,
25
- }: {
26
- operation: OperationsFragmentFragment;
27
- selectedServer: string;
28
- exampleBody: {
29
- mimeType: string;
30
- text?: string;
31
- };
32
- }) => {
33
- const isMultipart =
34
- exampleBody.mimeType === "multipart/form-data" ||
35
- exampleBody.mimeType === "application/x-www-form-urlencoded";
36
-
37
- const postData = isMultipart
38
- ? {
39
- mimeType: exampleBody.mimeType,
40
- params: toFormDataParams(exampleBody.text),
41
- }
42
- : exampleBody;
43
-
44
- return new HTTPSnippet({
45
- method: operation.method.toUpperCase(),
46
- url:
47
- selectedServer + operation.path.replaceAll("{", ":").replaceAll("}", ""),
48
- postData,
49
- headers: [
50
- ...(exampleBody.text
51
- ? [{ name: "Content-Type", value: exampleBody.mimeType }]
52
- : []),
53
- ...(operation.parameters
54
- ?.filter((p) => p.in === "header" && p.required === true)
55
- .map((p) => ({
56
- name: p.name,
57
- value:
58
- p.schema?.default ??
59
- p.examples?.find((x) => x.value)?.value ??
60
- (p.schema?.type === "string"
61
- ? "<string>"
62
- : p.schema?.type === "number" || p.schema?.type === "integer"
63
- ? "<number>"
64
- : p.schema?.type === "boolean"
65
- ? "<bool>"
66
- : "<value>"),
67
- })) ?? []),
68
- ],
69
- queryString:
70
- operation.parameters
71
- ?.filter((p) => p.in === "query" && p.required === true)
72
- .map((p) => ({
73
- name: p.name,
74
- value:
75
- p.schema?.default ??
76
- p.examples?.find((x) => x.value)?.value ??
77
- (p.schema?.type === "string"
78
- ? "<string>"
79
- : p.schema?.type === "number" || p.schema?.type === "integer"
80
- ? "<number>"
81
- : p.schema?.type === "boolean"
82
- ? "<bool>"
83
- : "<value>"),
84
- })) ?? [],
85
- httpVersion: "",
86
- cookies: [],
87
- headersSize: 0,
88
- bodySize: 0,
89
- });
90
- };
91
-
92
- export const getConverted = (snippet: HTTPSnippet, option: string) => {
93
- // biome-ignore lint/suspicious/noExplicitAny: Allow any type
94
- let converted: any;
95
- switch (option) {
96
- case "shell":
97
- converted = snippet.convert("shell", "curl");
98
- break;
99
- case "js":
100
- converted = snippet.convert("javascript", "fetch");
101
- break;
102
- case "python":
103
- converted = snippet.convert("python", "requests");
104
- break;
105
- case "java":
106
- converted = snippet.convert("java", "okhttp");
107
- break;
108
- case "go":
109
- converted = snippet.convert("go", "native");
110
- break;
111
- case "csharp":
112
- converted = snippet.convert("csharp", "httpclient");
113
- break;
114
- case "kotlin":
115
- converted = snippet.convert("kotlin", "okhttp");
116
- break;
117
- case "objc":
118
- converted = snippet.convert("objc", "nsurlsession");
119
- break;
120
- case "php":
121
- converted = snippet.convert("php", "http2");
122
- break;
123
- case "ruby":
124
- converted = snippet.convert("ruby");
125
- break;
126
- case "swift":
127
- converted = snippet.convert("swift");
128
- break;
129
- default:
130
- converted = snippet.convert("shell");
131
- break;
132
- }
133
-
134
- return converted ? converted[0] : "";
135
- };
1
+ import { HTTPSnippet } from "@zudoku/httpsnippet";
2
+ import type { OperationsFragmentFragment } from "../graphql/graphql.js";
3
+
4
+ /** Operation extension with the header a key goes in (same name as `KEY_HEADER_EXTENSION` in the CLI's config). */
5
+ export const KEY_HEADER_EXTENSION = "x-apitogo-key-header";
6
+
7
+ /** What a code sample shows where the visitor's key goes. */
8
+ export const KEY_PLACEHOLDER = "YOUR_API_KEY";
9
+
10
+ const toFormDataParams = (text?: string) => {
11
+ const stringify = (v: unknown) =>
12
+ typeof v === "string" ? v : JSON.stringify(v);
13
+
14
+ try {
15
+ const obj = text && JSON.parse(text);
16
+ if (typeof obj !== "object" || !obj) return [];
17
+
18
+ return Object.entries(obj).flatMap(([name, value]) => {
19
+ const values = Array.isArray(value) ? value : [value];
20
+ return values.map((v) => ({ name, value: stringify(v) }));
21
+ });
22
+ } catch {
23
+ return [];
24
+ }
25
+ };
26
+
27
+ export const createHttpSnippet = ({
28
+ operation,
29
+ selectedServer,
30
+ exampleBody,
31
+ }: {
32
+ operation: OperationsFragmentFragment;
33
+ selectedServer: string;
34
+ exampleBody: {
35
+ mimeType: string;
36
+ text?: string;
37
+ };
38
+ }) => {
39
+ const isMultipart =
40
+ exampleBody.mimeType === "multipart/form-data" ||
41
+ exampleBody.mimeType === "application/x-www-form-urlencoded";
42
+
43
+ const postData = isMultipart
44
+ ? {
45
+ mimeType: exampleBody.mimeType,
46
+ params: toFormDataParams(exampleBody.text),
47
+ }
48
+ : exampleBody;
49
+
50
+ const keyHeader = operation.extensions?.[KEY_HEADER_EXTENSION];
51
+
52
+ return new HTTPSnippet({
53
+ method: operation.method.toUpperCase(),
54
+ url:
55
+ selectedServer + operation.path.replaceAll("{", ":").replaceAll("}", ""),
56
+ postData,
57
+ headers: [
58
+ // The gateway's key header, on operations that need a key (set by the portal build, API-120).
59
+ ...(typeof keyHeader === "string" && keyHeader
60
+ ? [{ name: keyHeader, value: KEY_PLACEHOLDER }]
61
+ : []),
62
+ ...(exampleBody.text
63
+ ? [{ name: "Content-Type", value: exampleBody.mimeType }]
64
+ : []),
65
+ ...(operation.parameters
66
+ ?.filter((p) => p.in === "header" && p.required === true)
67
+ .map((p) => ({
68
+ name: p.name,
69
+ value:
70
+ p.schema?.default ??
71
+ p.examples?.find((x) => x.value)?.value ??
72
+ (p.schema?.type === "string"
73
+ ? "<string>"
74
+ : p.schema?.type === "number" || p.schema?.type === "integer"
75
+ ? "<number>"
76
+ : p.schema?.type === "boolean"
77
+ ? "<bool>"
78
+ : "<value>"),
79
+ })) ?? []),
80
+ ],
81
+ queryString:
82
+ operation.parameters
83
+ ?.filter((p) => p.in === "query" && p.required === true)
84
+ .map((p) => ({
85
+ name: p.name,
86
+ value:
87
+ p.schema?.default ??
88
+ p.examples?.find((x) => x.value)?.value ??
89
+ (p.schema?.type === "string"
90
+ ? "<string>"
91
+ : p.schema?.type === "number" || p.schema?.type === "integer"
92
+ ? "<number>"
93
+ : p.schema?.type === "boolean"
94
+ ? "<bool>"
95
+ : "<value>"),
96
+ })) ?? [],
97
+ httpVersion: "",
98
+ cookies: [],
99
+ headersSize: 0,
100
+ bodySize: 0,
101
+ });
102
+ };
103
+
104
+ export const getConverted = (snippet: HTTPSnippet, option: string) => {
105
+ // biome-ignore lint/suspicious/noExplicitAny: Allow any type
106
+ let converted: any;
107
+ switch (option) {
108
+ case "shell":
109
+ converted = snippet.convert("shell", "curl");
110
+ break;
111
+ case "js":
112
+ converted = snippet.convert("javascript", "fetch");
113
+ break;
114
+ case "python":
115
+ converted = snippet.convert("python", "requests");
116
+ break;
117
+ case "java":
118
+ converted = snippet.convert("java", "okhttp");
119
+ break;
120
+ case "go":
121
+ converted = snippet.convert("go", "native");
122
+ break;
123
+ case "csharp":
124
+ converted = snippet.convert("csharp", "httpclient");
125
+ break;
126
+ case "kotlin":
127
+ converted = snippet.convert("kotlin", "okhttp");
128
+ break;
129
+ case "objc":
130
+ converted = snippet.convert("objc", "nsurlsession");
131
+ break;
132
+ case "php":
133
+ converted = snippet.convert("php", "http2");
134
+ break;
135
+ case "ruby":
136
+ converted = snippet.convert("ruby");
137
+ break;
138
+ case "swift":
139
+ converted = snippet.convert("swift");
140
+ break;
141
+ default:
142
+ converted = snippet.convert("shell");
143
+ break;
144
+ }
145
+
146
+ return converted ? converted[0] : "";
147
+ };