@lukoweb/apitogo 0.2.47 → 0.2.50
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.
- package/dist/cli/cli.js +1034 -761
- package/docs/cli/shape.md +11 -10
- package/docs/cli/walkthrough.md +14 -25
- package/docs/configuration/make-config.mdx +16 -12
- package/docs/configuration/navigation.mdx +1 -1
- package/docs/guides/custom-pages.md +33 -4
- package/package.json +1 -1
- package/src/config/v2/__fixtures__/scenario-a-docs-only.json +6 -6
- package/src/config/v2/__fixtures__/scenario-b-managed-origin.json +14 -14
- package/src/config/v2/__fixtures__/scenario-c-proxy-oidc.json +15 -15
- package/src/config/v2/__fixtures__/scenario-h-full-stack.json +28 -28
- package/src/config/v2/billing.ts +59 -0
- package/src/config/v2/docs-navigation.ts +95 -72
- package/src/config/v2/errors.ts +42 -42
- package/src/config/v2/gateway-reference.ts +220 -220
- package/src/config/v2/gateway-request.ts +123 -123
- package/src/config/v2/index.ts +8 -0
- package/src/config/v2/layout.ts +237 -237
- package/src/config/v2/load.ts +70 -70
- package/src/config/v2/parse.ts +214 -214
- package/src/config/v2/portal-profile.ts +88 -88
- package/src/config/v2/resolve.ts +6 -1
- package/src/config/v2/rules.ts +31 -16
- package/src/config/v2/services.ts +5 -20
- package/src/config/v2/tsx-pages.ts +100 -0
- package/src/config/v2/workspace.ts +5 -0
- package/src/config/v2/wrapper.ts +88 -9
package/src/config/v2/rules.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { readdir, readFile, stat } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { glob } from "glob";
|
|
4
4
|
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import { billingNeeded, stripeKeySecretName } from "./billing.js";
|
|
5
6
|
import type { ConfigIssue } from "./errors.js";
|
|
6
7
|
import { type ResolvedConfigV2, toGlobPath } from "./resolve.js";
|
|
7
8
|
import type { ConfigV2 } from "./schema.js";
|
|
9
|
+
import { findTsxPages } from "./tsx-pages.js";
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* The rules of `apitogo check` that need no network (Reference, section 2). Online rules (provider capabilities,
|
|
@@ -276,6 +278,8 @@ async function docsContentRule(
|
|
|
276
278
|
? await glob(include, { nodir: true, ignore, absolute: true })
|
|
277
279
|
: [];
|
|
278
280
|
if (matches.length > 0) return;
|
|
281
|
+
// React pages (.tsx) next to the docs count too.
|
|
282
|
+
if ((await findTsxPages(patterns)).length > 0) return;
|
|
279
283
|
|
|
280
284
|
const shown = config.portal?.docs?.content ?? [
|
|
281
285
|
path.relative(resolved.configDir, patterns[0] ?? ""),
|
|
@@ -284,7 +288,7 @@ async function docsContentRule(
|
|
|
284
288
|
code: "PORTAL_DOCS_EMPTY",
|
|
285
289
|
path: "portal.docs",
|
|
286
290
|
message: `portal.docs matched 0 files (${shown.join(", ")}). The site will only contain the API reference.`,
|
|
287
|
-
hint: "Add .md or .
|
|
291
|
+
hint: "Add .md, .mdx or .tsx pages, or point docs.content at your existing docs.",
|
|
288
292
|
});
|
|
289
293
|
}
|
|
290
294
|
|
|
@@ -657,9 +661,9 @@ function identityRules(
|
|
|
657
661
|
// --- plans (2.6) ----------------------------------------------------------------------------------------------------
|
|
658
662
|
|
|
659
663
|
async function plansRules(
|
|
660
|
-
config: ConfigV2,
|
|
661
664
|
resolved: ResolvedConfigV2,
|
|
662
665
|
out: Collector,
|
|
666
|
+
paid: boolean,
|
|
663
667
|
) {
|
|
664
668
|
const plans = resolved.plans;
|
|
665
669
|
if (!plans?.enabled) return;
|
|
@@ -683,16 +687,8 @@ async function plansRules(
|
|
|
683
687
|
}
|
|
684
688
|
}
|
|
685
689
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
out.error({
|
|
689
|
-
code: "PLANS_BILLING_INCOMPLETE",
|
|
690
|
-
path: "plans.billing.apiKey",
|
|
691
|
-
message:
|
|
692
|
-
"plans.billing.apiKey is required when billing.provider is stripe.",
|
|
693
|
-
hint: 'Set it to a secret reference, for example "secret:STRIPE_API_KEY".',
|
|
694
|
-
});
|
|
695
|
-
}
|
|
690
|
+
// Billing is on only when a plan has a price. The Stripe key defaults to the STRIPE_API_KEY secret.
|
|
691
|
+
if (paid) {
|
|
696
692
|
const headless = !(resolved.portal?.enabled === true);
|
|
697
693
|
for (const field of ["successUrl", "cancelUrl"] as const) {
|
|
698
694
|
if (headless && !plans.billing[field]) {
|
|
@@ -711,11 +707,22 @@ async function plansRules(
|
|
|
711
707
|
|
|
712
708
|
function secretRules(
|
|
713
709
|
config: ConfigV2,
|
|
710
|
+
resolved: ResolvedConfigV2,
|
|
714
711
|
context: CheckContext,
|
|
715
712
|
out: Collector,
|
|
716
713
|
skipped: string[],
|
|
714
|
+
paid: boolean,
|
|
717
715
|
) {
|
|
718
|
-
|
|
716
|
+
// The billing secrets matter only when a plan has a price. The Stripe key is then needed even when the config does not
|
|
717
|
+
// name it (it defaults to STRIPE_API_KEY). `apitogo deploy` asks for it, so a missing key is a warning, not an error.
|
|
718
|
+
const stripeKeyPath = "plans.billing.apiKey";
|
|
719
|
+
const named = collectSecretRefs(config).filter(
|
|
720
|
+
(ref) => paid || !ref.path.startsWith("plans.billing"),
|
|
721
|
+
);
|
|
722
|
+
const refs =
|
|
723
|
+
paid && !named.some((ref) => ref.path === stripeKeyPath)
|
|
724
|
+
? [...named, { name: stripeKeySecretName(resolved), path: stripeKeyPath }]
|
|
725
|
+
: named;
|
|
719
726
|
if (refs.length === 0) return;
|
|
720
727
|
if (!context.knownSecrets) {
|
|
721
728
|
skipped.push(
|
|
@@ -724,7 +731,14 @@ function secretRules(
|
|
|
724
731
|
return;
|
|
725
732
|
}
|
|
726
733
|
for (const ref of refs) {
|
|
727
|
-
if (!context.knownSecrets.has(ref.name)) {
|
|
734
|
+
if (ref.path === stripeKeyPath && !context.knownSecrets.has(ref.name)) {
|
|
735
|
+
out.warn({
|
|
736
|
+
code: "SECRET_NOT_SET",
|
|
737
|
+
path: ref.path,
|
|
738
|
+
message: `a plan has a price, and the Stripe key (secret ${ref.name}) is not set yet.`,
|
|
739
|
+
hint: `apitogo deploy asks for it. Or run: apitogo secrets set ${ref.name}`,
|
|
740
|
+
});
|
|
741
|
+
} else if (!context.knownSecrets.has(ref.name)) {
|
|
728
742
|
out.error({
|
|
729
743
|
code: "SECRET_NOT_SET",
|
|
730
744
|
path: ref.path,
|
|
@@ -751,12 +765,13 @@ export async function checkConfigV2(
|
|
|
751
765
|
warn: (issue) => warnings.push(issue),
|
|
752
766
|
};
|
|
753
767
|
|
|
768
|
+
const paid = await billingNeeded(resolved);
|
|
754
769
|
dependencyRules(config, resolved, out);
|
|
755
|
-
secretRules(config, context, out, skipped);
|
|
770
|
+
secretRules(config, resolved, context, out, skipped, paid);
|
|
756
771
|
await portalRules(config, resolved, out);
|
|
757
772
|
await gatewayRules(resolved, out);
|
|
758
773
|
identityRules(config, resolved, out);
|
|
759
|
-
await plansRules(
|
|
774
|
+
await plansRules(resolved, out, paid);
|
|
760
775
|
|
|
761
776
|
return { errors, warnings, skipped };
|
|
762
777
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "node:fs/promises";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { ApitogoError } from "../../lib/util/invariant.js";
|
|
12
|
+
import { isPaidPlan } from "./billing.js";
|
|
12
13
|
import {
|
|
13
14
|
FULL_SERVER_HEALTH_PATH,
|
|
14
15
|
FULL_SERVER_TIMEOUT_SECONDS,
|
|
@@ -182,7 +183,6 @@ export type AddOptions = {
|
|
|
182
183
|
plans?: {
|
|
183
184
|
file?: string;
|
|
184
185
|
template?: "free" | "tiered" | "none";
|
|
185
|
-
billing?: "stripe" | "none";
|
|
186
186
|
};
|
|
187
187
|
};
|
|
188
188
|
|
|
@@ -773,15 +773,8 @@ async function buildPlans(
|
|
|
773
773
|
: isFilled(options.template)
|
|
774
774
|
? "starter"
|
|
775
775
|
: "free");
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
const billing = o.billing ?? (template === "tiered" ? "stripe" : "none");
|
|
779
|
-
if (template === "tiered" && billing === "none") {
|
|
780
|
-
throw new ServiceEditError(
|
|
781
|
-
"The tiered template has paid plans, which need a billing provider.",
|
|
782
|
-
"Use --billing stripe, or --template free.",
|
|
783
|
-
);
|
|
784
|
-
}
|
|
776
|
+
// Stripe is the only billing provider, and it is needed only once a plan has a price: the config names no billing
|
|
777
|
+
// settings, and `apitogo deploy` asks for the Stripe key (secret STRIPE_API_KEY) the first time a paid plan is deployed.
|
|
785
778
|
const file = o.file ? insideProject(dir, o.file, "Plans file") : undefined;
|
|
786
779
|
const target = file
|
|
787
780
|
? path.join(dir, file)
|
|
@@ -791,22 +784,14 @@ async function buildPlans(
|
|
|
791
784
|
target,
|
|
792
785
|
`${JSON.stringify({ version: 1, plans: PLAN_TEMPLATES[template] }, null, 2)}\n`,
|
|
793
786
|
);
|
|
794
|
-
if (
|
|
787
|
+
if (PLAN_TEMPLATES[template].some((plan) => isPaidPlan(plan))) {
|
|
795
788
|
context.notes.push(
|
|
796
|
-
"
|
|
789
|
+
"A plan has a price, so payments go through Stripe: apitogo deploy asks for your Stripe secret key.",
|
|
797
790
|
);
|
|
798
791
|
}
|
|
799
792
|
return {
|
|
800
793
|
...(options.disabled ? { enabled: false } : {}),
|
|
801
794
|
...(file ? { file } : {}),
|
|
802
|
-
...(billing === "stripe"
|
|
803
|
-
? {
|
|
804
|
-
billing: {
|
|
805
|
-
provider: "stripe" as const,
|
|
806
|
-
apiKey: "secret:STRIPE_API_KEY",
|
|
807
|
-
},
|
|
808
|
-
}
|
|
809
|
-
: { billing: { provider: "none" as const } }),
|
|
810
795
|
};
|
|
811
796
|
}
|
|
812
797
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { glob } from "glob";
|
|
4
|
+
import globParent from "glob-parent";
|
|
5
|
+
import { folderLabel } from "./docs-navigation.js";
|
|
6
|
+
import { toGlobPath } from "./resolve.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* React pages next to the Markdown docs: a `.tsx` file in a docs folder is a page whose default export is the page
|
|
10
|
+
* component. Its address follows the same rule as a Markdown page (the path inside the folder, without the extension),
|
|
11
|
+
* so `pages/guides/quickstart.tsx` is `/guides/quickstart`. A file or folder whose name starts with `_` is a helper
|
|
12
|
+
* (shared components, hooks), not a page.
|
|
13
|
+
*/
|
|
14
|
+
export type TsxPage = {
|
|
15
|
+
/** `/guides/quickstart`. */
|
|
16
|
+
route: string;
|
|
17
|
+
/** Absolute path of the component file. */
|
|
18
|
+
file: string;
|
|
19
|
+
/** Sidebar label: `export const title = "..."` in the file, or the file name. */
|
|
20
|
+
label: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const IGNORED = ["**/node_modules/**", "**/dist/**", "**/.git/**"];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A helper next to the pages, not a page: anything under a name that starts with `_`, tests and stories. Checked on
|
|
27
|
+
* the path inside the docs folder (the glob library's `ignore` does not match absolute Windows paths).
|
|
28
|
+
*/
|
|
29
|
+
export function isTsxHelper(relative: string): boolean {
|
|
30
|
+
return (
|
|
31
|
+
relative.split("/").some((part) => part.startsWith("_")) ||
|
|
32
|
+
/\.(test|spec|stories)\.tsx$/.test(relative) ||
|
|
33
|
+
relative.split("/").includes("node_modules")
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The `.tsx` patterns for the docs content globs: every file below each glob's folder. Negated globs stay as they are. */
|
|
38
|
+
export function tsxPagePatterns(contentGlobs: readonly string[]): string[] {
|
|
39
|
+
const globs = contentGlobs.map(toGlobPath);
|
|
40
|
+
const included = globs.filter((pattern) => !pattern.startsWith("!"));
|
|
41
|
+
const excluded = globs.filter((pattern) => pattern.startsWith("!"));
|
|
42
|
+
return [
|
|
43
|
+
...new Set(
|
|
44
|
+
included.map((pattern) =>
|
|
45
|
+
path.posix.join(globParent(pattern), "**", "*.tsx"),
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
...excluded,
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const TITLE =
|
|
53
|
+
/export\s+const\s+title\s*(?::\s*string\s*)?=\s*(["'`])((?:\\.|(?!\1).)*)\1/;
|
|
54
|
+
|
|
55
|
+
/** The label a page shows in the sidebar. */
|
|
56
|
+
export function tsxPageLabel(source: string, file: string): string {
|
|
57
|
+
const match = TITLE.exec(source);
|
|
58
|
+
if (match?.[2]) return match[2].replace(/\\(.)/g, "$1");
|
|
59
|
+
const name = path.basename(file, ".tsx");
|
|
60
|
+
return folderLabel(
|
|
61
|
+
name === "index" ? path.basename(path.dirname(file)) : name,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The React pages the docs content globs reach, sorted by address. */
|
|
66
|
+
export async function findTsxPages(
|
|
67
|
+
contentGlobs: readonly string[],
|
|
68
|
+
): Promise<TsxPage[]> {
|
|
69
|
+
const patterns = tsxPagePatterns(contentGlobs);
|
|
70
|
+
const ignore = [
|
|
71
|
+
...IGNORED,
|
|
72
|
+
...patterns
|
|
73
|
+
.filter((pattern) => pattern.startsWith("!"))
|
|
74
|
+
.map((pattern) => pattern.slice(1)),
|
|
75
|
+
];
|
|
76
|
+
const found = new Map<string, TsxPage>();
|
|
77
|
+
for (const pattern of patterns.filter((p) => !p.startsWith("!"))) {
|
|
78
|
+
const parent = globParent(pattern);
|
|
79
|
+
const files = await glob(pattern, {
|
|
80
|
+
absolute: true,
|
|
81
|
+
posix: true,
|
|
82
|
+
nodir: true,
|
|
83
|
+
ignore,
|
|
84
|
+
});
|
|
85
|
+
for (const raw of files.sort()) {
|
|
86
|
+
// On Windows the glob library answers with the extended form `//?/C:/...`, which would not relate to `parent`.
|
|
87
|
+
const file = raw.replace(/^\/\/\?\//, "");
|
|
88
|
+
const relative = path.posix.relative(parent, file);
|
|
89
|
+
if (isTsxHelper(relative)) continue;
|
|
90
|
+
const route = `/${relative.replace(/\.tsx$/, "")}`;
|
|
91
|
+
if (found.has(route)) continue;
|
|
92
|
+
const source = await readFile(file, "utf8").catch(() => "");
|
|
93
|
+
found.set(route, { route, file, label: tsxPageLabel(source, file) });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return [...found.values()].sort((a, b) => a.route.localeCompare(b.route));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The placeholder a sidebar item carries until the generated wrapper puts the component in its place. */
|
|
100
|
+
export const TSX_PAGE_ELEMENT_PREFIX = "apitogo:tsx-page:";
|
|
@@ -5,6 +5,7 @@ import { glob } from "glob";
|
|
|
5
5
|
import { ensureStateDir } from "./layout.js";
|
|
6
6
|
import { linkDirectory } from "./link-directory.js";
|
|
7
7
|
import type { ResolvedConfigV2 } from "./resolve.js";
|
|
8
|
+
import { tsxPagePatterns } from "./tsx-pages.js";
|
|
8
9
|
import { CONTENT_LINK, type PreparedPortal } from "./wrapper.js";
|
|
9
10
|
|
|
10
11
|
const exists = (file: string) =>
|
|
@@ -92,6 +93,10 @@ export async function portalArtifactHash(
|
|
|
92
93
|
const portal = resolved.portal;
|
|
93
94
|
const patterns = [
|
|
94
95
|
...(portal?.docs.content ?? []),
|
|
96
|
+
// React docs pages and the helpers next to them are imported by the wrapper, so their contents decide the build too.
|
|
97
|
+
...(portal?.docs.provider === "apitogo"
|
|
98
|
+
? tsxPagePatterns(portal.docs.content)
|
|
99
|
+
: []),
|
|
95
100
|
...(resolved.gateway?.specs ?? []),
|
|
96
101
|
...(portal
|
|
97
102
|
? [portal.theme, path.join(portal.dir, "public", "**", "*")]
|
package/src/config/v2/wrapper.ts
CHANGED
|
@@ -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
|
|
|
@@ -424,8 +425,17 @@ function authenticationFor(plan: PortalPlan): Record<string, unknown> {
|
|
|
424
425
|
export const PREVIEW_BANNER_MESSAGE =
|
|
425
426
|
"Preview mode: sign in with demo@apitogo.local / password to try the console. Run `apitogo deploy` for the real services.";
|
|
426
427
|
|
|
427
|
-
/**
|
|
428
|
-
export
|
|
428
|
+
/** A React docs page the wrapper imports: `importPath` is relative to the wrapper, through the `content` link. */
|
|
429
|
+
export type WrapperPage = { id: string; importPath: string };
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The wrapper source. Imports exist only for what the plan registers. React docs pages are imported here and put in
|
|
433
|
+
* place of their sidebar placeholders, since the site config is JSON and cannot hold a component.
|
|
434
|
+
*/
|
|
435
|
+
export function renderWrapper(
|
|
436
|
+
plan: PortalPlan,
|
|
437
|
+
pages: readonly WrapperPage[] = [],
|
|
438
|
+
): string {
|
|
429
439
|
const imports = [
|
|
430
440
|
'import { buildApitogoConfig } from "@lukoweb/apitogo/build-config";',
|
|
431
441
|
...(plan.plansPlugin
|
|
@@ -433,12 +443,41 @@ export function renderWrapper(plan: PortalPlan): string {
|
|
|
433
443
|
'import { devPortalBillingPlugin } from "@lukoweb/apitogo-plugin-dev-portal-billing";',
|
|
434
444
|
]
|
|
435
445
|
: []),
|
|
446
|
+
...(pages.length > 0 ? ['import { createElement } from "react";'] : []),
|
|
447
|
+
...pages.map(
|
|
448
|
+
(page, index) =>
|
|
449
|
+
`import TsxPage${index} from ${JSON.stringify(page.importPath)};`,
|
|
450
|
+
),
|
|
436
451
|
`import siteConfig from "./${SITE_CONFIG_FILE}";`,
|
|
437
452
|
];
|
|
438
453
|
const plugins = plan.plansPlugin
|
|
439
454
|
? `[devPortalBillingPlugin(${JSON.stringify(plan.plansPlugin)})]`
|
|
440
455
|
: "[]";
|
|
441
456
|
|
|
457
|
+
const pageLines =
|
|
458
|
+
pages.length === 0
|
|
459
|
+
? []
|
|
460
|
+
: [
|
|
461
|
+
"",
|
|
462
|
+
"// React docs pages: each sidebar placeholder becomes the page's component.",
|
|
463
|
+
"const tsxPages: Record<string, any> = {",
|
|
464
|
+
...pages.map(
|
|
465
|
+
(page, index) => ` ${JSON.stringify(page.id)}: TsxPage${index},`,
|
|
466
|
+
),
|
|
467
|
+
"};",
|
|
468
|
+
"const withTsxPages = (items: any[]): any[] =>",
|
|
469
|
+
" items.map((item) => {",
|
|
470
|
+
' if (item === null || typeof item !== "object") return item;',
|
|
471
|
+
" const page =",
|
|
472
|
+
' typeof item.element === "string" ? tsxPages[item.element] : undefined;',
|
|
473
|
+
" return {",
|
|
474
|
+
" ...item,",
|
|
475
|
+
" ...(page ? { element: createElement(page) } : {}),",
|
|
476
|
+
" ...(Array.isArray(item.items) ? { items: withTsxPages(item.items) } : {}),",
|
|
477
|
+
" };",
|
|
478
|
+
" });",
|
|
479
|
+
];
|
|
480
|
+
|
|
442
481
|
return [
|
|
443
482
|
"// Generated by apitogo from apitogo.config.json and services.json. Do not edit: it is rewritten on every build.",
|
|
444
483
|
...(plan.preview
|
|
@@ -447,11 +486,28 @@ export function renderWrapper(plan: PortalPlan): string {
|
|
|
447
486
|
]
|
|
448
487
|
: []),
|
|
449
488
|
...imports,
|
|
489
|
+
...pageLines,
|
|
450
490
|
"",
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
491
|
+
// The components go in after buildApitogoConfig: it copies the config with structuredClone, which cannot copy a
|
|
492
|
+
// React element.
|
|
493
|
+
...(pages.length > 0
|
|
494
|
+
? [
|
|
495
|
+
"const config = buildApitogoConfig({",
|
|
496
|
+
" ...siteConfig,",
|
|
497
|
+
` plugins: ${plugins},`,
|
|
498
|
+
"});",
|
|
499
|
+
"",
|
|
500
|
+
"export default {",
|
|
501
|
+
" ...config,",
|
|
502
|
+
" navigation: withTsxPages((config as any).navigation ?? []),",
|
|
503
|
+
"};",
|
|
504
|
+
]
|
|
505
|
+
: [
|
|
506
|
+
"export default buildApitogoConfig({",
|
|
507
|
+
" ...siteConfig,",
|
|
508
|
+
` plugins: ${plugins},`,
|
|
509
|
+
"});",
|
|
510
|
+
]),
|
|
455
511
|
"",
|
|
456
512
|
].join("\n");
|
|
457
513
|
}
|
|
@@ -580,11 +636,27 @@ export async function preparePortal(
|
|
|
580
636
|
});
|
|
581
637
|
}
|
|
582
638
|
}
|
|
583
|
-
const
|
|
639
|
+
const markdownRoutes = plan.docs
|
|
584
640
|
? await docRoutes(profile.docs.content, {
|
|
585
641
|
includeDrafts: mode === "development",
|
|
586
642
|
})
|
|
587
643
|
: [];
|
|
644
|
+
// React pages next to the Markdown ones. A Markdown page wins over a React page at the same address.
|
|
645
|
+
const tsxPages = plan.docs
|
|
646
|
+
? (await findTsxPages(profile.docs.content)).filter(
|
|
647
|
+
(page) => !markdownRoutes.includes(page.route),
|
|
648
|
+
)
|
|
649
|
+
: [];
|
|
650
|
+
const wrapperPages: WrapperPage[] = tsxPages.map((page, index) => ({
|
|
651
|
+
id: `${TSX_PAGE_ELEMENT_PREFIX}${index}`,
|
|
652
|
+
importPath: `./${CONTENT_LINK}/${path
|
|
653
|
+
.relative(resolved.configDir, page.file)
|
|
654
|
+
.split(path.sep)
|
|
655
|
+
.join("/")}`,
|
|
656
|
+
}));
|
|
657
|
+
const hostedDocRoutes = [
|
|
658
|
+
...new Set([...markdownRoutes, ...tsxPages.map((page) => page.route)]),
|
|
659
|
+
].sort();
|
|
588
660
|
// The account pages' "Documentation" link goes to a page the portal really has, or is left out.
|
|
589
661
|
if (plan.plansPlugin && plan.docs) {
|
|
590
662
|
const landing = hostedDocRoutes.some(
|
|
@@ -617,7 +689,14 @@ export async function preparePortal(
|
|
|
617
689
|
await readTheme(resolved),
|
|
618
690
|
{
|
|
619
691
|
redirects: home ? [home] : [],
|
|
620
|
-
docsNavigation: docsNavigation(
|
|
692
|
+
docsNavigation: docsNavigation(
|
|
693
|
+
markdownRoutes,
|
|
694
|
+
tsxPages.map((page, index) => ({
|
|
695
|
+
route: page.route,
|
|
696
|
+
label: page.label,
|
|
697
|
+
element: wrapperPages[index]?.id ?? "",
|
|
698
|
+
})),
|
|
699
|
+
),
|
|
621
700
|
docsPath: pickLandingRoute(hostedDocRoutes),
|
|
622
701
|
demoPlans:
|
|
623
702
|
plan.demo && plan.plansPlugin
|
|
@@ -633,7 +712,7 @@ export async function preparePortal(
|
|
|
633
712
|
}),
|
|
634
713
|
},
|
|
635
714
|
);
|
|
636
|
-
const wrapper = renderWrapper(plan);
|
|
715
|
+
const wrapper = renderWrapper(plan, wrapperPages);
|
|
637
716
|
const wrapperPath = path.join(resolved.state.generatedDir, WRAPPER_FILE);
|
|
638
717
|
if (options.write !== false) {
|
|
639
718
|
await mkdir(path.dirname(wrapperPath), { recursive: true });
|