@lukoweb/apitogo 0.1.50 → 0.1.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.
@@ -1,17 +1,26 @@
1
1
  import { access, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
+ import {
5
+ APITOGO_CONFIG_FILENAME,
6
+ extractManifest,
7
+ loadApitogoConfig,
8
+ } from "./apitogo-config-loader.js";
4
9
 
5
- /** @deprecated Use {@link APITOGO_MANIFEST_ENV_FILES.development} — kept for external consumers. */
10
+ /** @deprecated Use {@link APITOGO_CONFIG_FILENAME}. */
6
11
  export const DEV_PORTAL_MANIFEST_FILENAME = "apitogo.local.json";
7
12
 
13
+ /** @deprecated Use {@link APITOGO_CONFIG_FILENAME}. */
8
14
  export const APITOGO_MANIFEST_BASE_FILENAME = "apitogo.json";
9
15
 
16
+ /** @deprecated Per-environment manifest overrides were replaced by apitogo.config.json + .env. */
10
17
  export const APITOGO_MANIFEST_ENV_FILES = {
11
18
  development: "apitogo.local.json",
12
19
  production: "apitogo.prod.json",
13
20
  } as const;
14
21
 
22
+ export { APITOGO_CONFIG_FILENAME };
23
+
15
24
  export type ApitogoManifestEnv = keyof typeof APITOGO_MANIFEST_ENV_FILES;
16
25
 
17
26
  const BillingPeriodSchema = z.enum(["month", "year", "monthly", "annual"]);
@@ -40,6 +49,11 @@ export const DevPortalLocalManifestSchema = z.object({
40
49
  enabled: z.boolean().optional(),
41
50
  })
42
51
  .optional(),
52
+ authentication: z
53
+ .object({
54
+ enabled: z.boolean().optional(),
55
+ })
56
+ .optional(),
43
57
  plans: z.array(ManifestPlanInputSchema).optional(),
44
58
  backend: z
45
59
  .object({
@@ -83,6 +97,7 @@ export type DevPortalPreviewPlan = {
83
97
 
84
98
  export type DevPortalPreviewPlanCatalog = {
85
99
  pricingEnabled: boolean;
100
+ authenticationEnabled: boolean;
86
101
  plans: DevPortalPreviewPlan[];
87
102
  };
88
103
 
@@ -92,6 +107,12 @@ export function isPricingEnabled(
92
107
  return manifest?.pricing?.enabled === true;
93
108
  }
94
109
 
110
+ export function isAuthenticationEnabled(
111
+ manifest: DevPortalLocalManifest | null | undefined,
112
+ ): boolean {
113
+ return manifest?.authentication?.enabled === true;
114
+ }
115
+
95
116
  export function manifestToPreviewCatalog(
96
117
  manifest: DevPortalLocalManifest | null | undefined,
97
118
  ): DevPortalPreviewPlanCatalog {
@@ -99,6 +120,7 @@ export function manifestToPreviewCatalog(
99
120
  const catalog = plansToPreviewCatalog(plans);
100
121
  return {
101
122
  pricingEnabled: isPricingEnabled(manifest),
123
+ authenticationEnabled: isAuthenticationEnabled(manifest),
102
124
  plans: catalog.plans,
103
125
  };
104
126
  }
@@ -170,6 +192,12 @@ export function mergeManifest(
170
192
  if (override.pricing) {
171
193
  merged.pricing = { ...base.pricing, ...override.pricing };
172
194
  }
195
+ if (override.authentication) {
196
+ merged.authentication = {
197
+ ...base.authentication,
198
+ ...override.authentication,
199
+ };
200
+ }
173
201
  if (override.backend) {
174
202
  merged.backend = { ...base.backend, ...override.backend };
175
203
  }
@@ -192,16 +220,15 @@ export function manifestFilePath(rootDir: string, filename?: string): string {
192
220
 
193
221
  export function manifestPathsForEnv(
194
222
  rootDir: string,
195
- env: ApitogoManifestEnv,
223
+ _env: ApitogoManifestEnv,
196
224
  ): { basePath: string; overridePath: string; watchPaths: string[] } {
197
225
  const resolvedRoot = path.resolve(rootDir);
198
- const basePath = path.join(resolvedRoot, APITOGO_MANIFEST_BASE_FILENAME);
199
- const overridePath = path.join(resolvedRoot, APITOGO_MANIFEST_ENV_FILES[env]);
226
+ const configPath = path.join(resolvedRoot, APITOGO_CONFIG_FILENAME);
200
227
 
201
228
  return {
202
- basePath,
203
- overridePath,
204
- watchPaths: [basePath, overridePath],
229
+ basePath: configPath,
230
+ overridePath: configPath,
231
+ watchPaths: [configPath],
205
232
  };
206
233
  }
207
234
 
@@ -243,11 +270,36 @@ export async function readManifestFile(
243
270
  }
244
271
  }
245
272
 
273
+ function parseManifestRecord(
274
+ manifest: ReturnType<typeof extractManifest>,
275
+ ): DevPortalLocalManifest | null {
276
+ if (!manifest) {
277
+ return null;
278
+ }
279
+
280
+ const result = DevPortalLocalManifestSchema.safeParse(manifest);
281
+ return result.success ? result.data : null;
282
+ }
283
+
246
284
  export async function readEffectiveManifest(
247
285
  rootDir: string,
248
286
  env: ApitogoManifestEnv,
249
287
  ): Promise<DevPortalLocalManifest | null> {
250
- const { basePath, overridePath } = manifestPathsForEnv(rootDir, env);
288
+ try {
289
+ const config = loadApitogoConfig(rootDir);
290
+ return parseManifestRecord(extractManifest(config));
291
+ } catch {
292
+ return readLegacyEffectiveManifest(rootDir, env);
293
+ }
294
+ }
295
+
296
+ async function readLegacyEffectiveManifest(
297
+ rootDir: string,
298
+ env: ApitogoManifestEnv,
299
+ ): Promise<DevPortalLocalManifest | null> {
300
+ const resolvedRoot = path.resolve(rootDir);
301
+ const basePath = path.join(resolvedRoot, APITOGO_MANIFEST_BASE_FILENAME);
302
+ const overridePath = path.join(resolvedRoot, APITOGO_MANIFEST_ENV_FILES[env]);
251
303
  const hasBase = await fileExists(basePath);
252
304
  const hasOverride = await fileExists(overridePath);
253
305
 
package/src/index.ts CHANGED
@@ -71,4 +71,11 @@ export {
71
71
  type ProblemJson,
72
72
  throwIfProblemJson,
73
73
  } from "./lib/util/problemJson.js";
74
- export type { MdxComponentsType } from "./lib/util/MdxComponents.js";
74
+ export { buildApitogoConfig } from "./config/build-apitogo-config.js";
75
+ export {
76
+ APITOGO_CONFIG_FILENAME,
77
+ applyEnvOverrides,
78
+ extractManifest,
79
+ extractSiteConfig,
80
+ loadApitogoConfig,
81
+ } from "./config/apitogo-config-loader.js";
@@ -0,0 +1,56 @@
1
+ import type { HeaderNavigation } from "../../config/validators/HeaderNavigationSchema.js";
2
+ import type { ZudokuConfig } from "../../config/validators/ZudokuConfig.js";
3
+
4
+ export const LOGIN_NAV_PATH = "/signin";
5
+
6
+ export const LOGIN_HEADER_NAV_ITEM = {
7
+ label: "Login",
8
+ to: LOGIN_NAV_PATH,
9
+ display: "anon" as const,
10
+ };
11
+
12
+ export function withoutLoginNavigation(
13
+ navigation: HeaderNavigation,
14
+ ): HeaderNavigation {
15
+ return navigation.filter(
16
+ (item) => !("to" in item && item.to === LOGIN_NAV_PATH),
17
+ );
18
+ }
19
+
20
+ export function applyAuthenticationHeaderNav<T extends ZudokuConfig>(
21
+ config: T,
22
+ ): T {
23
+ if (!config.authentication) {
24
+ return config;
25
+ }
26
+
27
+ const authNavEnabled = config.__meta?.authenticationEnabled === true;
28
+ const navigation = config.header?.navigation ?? [];
29
+
30
+ if (!authNavEnabled) {
31
+ const filtered = withoutLoginNavigation(navigation);
32
+ if (filtered.length === navigation.length) {
33
+ return config;
34
+ }
35
+
36
+ return {
37
+ ...config,
38
+ header: {
39
+ ...config.header,
40
+ navigation: filtered,
41
+ },
42
+ };
43
+ }
44
+
45
+ if (navigation.some((item) => "to" in item && item.to === LOGIN_NAV_PATH)) {
46
+ return config;
47
+ }
48
+
49
+ return {
50
+ ...config,
51
+ header: {
52
+ ...config.header,
53
+ navigation: [...navigation, LOGIN_HEADER_NAV_ITEM],
54
+ },
55
+ };
56
+ }
@@ -1,5 +1,4 @@
1
1
  import { Button } from "@lukoweb/apitogo/ui/Button.js";
2
- import { Skeleton } from "@lukoweb/apitogo/ui/Skeleton.js";
3
2
  import { LogOutIcon } from "lucide-react";
4
3
  import { lazy, memo, Suspense } from "react";
5
4
  import { Link } from "react-router";
@@ -20,7 +19,6 @@ import {
20
19
  import { cn } from "../util/cn.js";
21
20
  import { joinUrl } from "../util/joinUrl.js";
22
21
  import { Banner } from "./Banner.js";
23
- import { ClientOnly } from "./ClientOnly.js";
24
22
  import { useZudoku } from "./context/ZudokuContext.js";
25
23
  import { MobileTopNavigation } from "./MobileTopNavigation.js";
26
24
  import { PageProgress } from "./PageProgress.js";
@@ -73,68 +71,58 @@ const ProfileMenu = () => {
73
71
  const context = useZudoku();
74
72
  const profileItems = context.getProfileMenuItems();
75
73
  const auth = useAuth();
76
- const { isAuthEnabled, isAuthenticated, isPending, profile } = auth;
74
+ const { isAuthEnabled, isAuthenticated, profile } = auth;
77
75
 
78
- if (!isAuthEnabled) return null;
76
+ if (!isAuthEnabled || !isAuthenticated) return null;
79
77
 
80
78
  return (
81
- <ClientOnly fallback={<Skeleton className="rounded-sm h-5 w-24 mr-4" />}>
82
- {isPending ? (
83
- <Skeleton className="rounded-sm h-9 w-24" />
84
- ) : !isAuthenticated ? (
85
- <Button size="lg" variant="ghost" onClick={() => auth.login()}>
86
- Login
79
+ <DropdownMenu modal={false}>
80
+ <DropdownMenuTrigger asChild>
81
+ <Button size="lg" variant="ghost">
82
+ {profile?.name ?? "My Account"}
87
83
  </Button>
88
- ) : (
89
- <DropdownMenu modal={false}>
90
- <DropdownMenuTrigger asChild>
91
- <Button size="lg" variant="ghost">
92
- {profile?.name ?? "My Account"}
93
- </Button>
94
- </DropdownMenuTrigger>
95
- <DropdownMenuContent className="w-56">
96
- <DropdownMenuLabel>
97
- {profile?.name ?? "My Account"}
98
- {profile?.email && profile.email !== profile?.name && (
99
- <div className="font-normal text-muted-foreground">
100
- {profile.email}
101
- </div>
102
- )}
103
- </DropdownMenuLabel>
104
- {profileItems.filter((i) => i.category === "top").length > 0 && (
105
- <DropdownMenuSeparator />
106
- )}
107
- {profileItems
108
- .filter((i) => i.category === "top")
109
- .map((i) => (
110
- <RecursiveMenu key={i.label} item={i} />
111
- ))}
112
- {profileItems.filter((i) => !i.category || i.category === "middle")
113
- .length > 0 && <DropdownMenuSeparator />}
114
- {profileItems
115
- .filter((i) => !i.category || i.category === "middle")
116
- .map((i) => (
117
- <RecursiveMenu key={i.label} item={i} />
118
- ))}
119
- {profileItems.filter((i) => i.category === "bottom").length > 0 && (
120
- <DropdownMenuSeparator />
121
- )}
122
- {profileItems
123
- .filter((i) => i.category === "bottom")
124
- .map((i) => (
125
- <RecursiveMenu key={i.label} item={i} />
126
- ))}
127
- <DropdownMenuSeparator />
128
- <Link to="/signout">
129
- <DropdownMenuItem className="flex gap-2">
130
- <LogOutIcon size={16} strokeWidth={1} absoluteStrokeWidth />
131
- Logout
132
- </DropdownMenuItem>
133
- </Link>
134
- </DropdownMenuContent>
135
- </DropdownMenu>
136
- )}
137
- </ClientOnly>
84
+ </DropdownMenuTrigger>
85
+ <DropdownMenuContent className="w-56">
86
+ <DropdownMenuLabel>
87
+ {profile?.name ?? "My Account"}
88
+ {profile?.email && profile.email !== profile?.name && (
89
+ <div className="font-normal text-muted-foreground">
90
+ {profile.email}
91
+ </div>
92
+ )}
93
+ </DropdownMenuLabel>
94
+ {profileItems.filter((i) => i.category === "top").length > 0 && (
95
+ <DropdownMenuSeparator />
96
+ )}
97
+ {profileItems
98
+ .filter((i) => i.category === "top")
99
+ .map((i) => (
100
+ <RecursiveMenu key={i.label} item={i} />
101
+ ))}
102
+ {profileItems.filter((i) => !i.category || i.category === "middle")
103
+ .length > 0 && <DropdownMenuSeparator />}
104
+ {profileItems
105
+ .filter((i) => !i.category || i.category === "middle")
106
+ .map((i) => (
107
+ <RecursiveMenu key={i.label} item={i} />
108
+ ))}
109
+ {profileItems.filter((i) => i.category === "bottom").length > 0 && (
110
+ <DropdownMenuSeparator />
111
+ )}
112
+ {profileItems
113
+ .filter((i) => i.category === "bottom")
114
+ .map((i) => (
115
+ <RecursiveMenu key={i.label} item={i} />
116
+ ))}
117
+ <DropdownMenuSeparator />
118
+ <Link to="/signout">
119
+ <DropdownMenuItem className="flex gap-2">
120
+ <LogOutIcon size={16} strokeWidth={1} absoluteStrokeWidth />
121
+ Logout
122
+ </DropdownMenuItem>
123
+ </Link>
124
+ </DropdownMenuContent>
125
+ </DropdownMenu>
138
126
  );
139
127
  };
140
128
  export const Header = memo(function HeaderInner() {
@@ -1,6 +1,5 @@
1
1
  import { Button } from "@lukoweb/apitogo/ui/Button.js";
2
2
  import { Separator } from "@lukoweb/apitogo/ui/Separator.js";
3
- import { Skeleton } from "@lukoweb/apitogo/ui/Skeleton.js";
4
3
  import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
5
4
  import { deepEqual } from "fast-equals";
6
5
  import {
@@ -28,7 +27,6 @@ import {
28
27
  DrawerTitle,
29
28
  DrawerTrigger,
30
29
  } from "../ui/Drawer.js";
31
- import { ClientOnly } from "./ClientOnly.js";
32
30
  import { useCurrentNavigation, useZudoku } from "./context/ZudokuContext.js";
33
31
  import { PoweredByZudoku } from "./navigation/PoweredByZudoku.js";
34
32
  import { getFirstMatchingPath, shouldShowItem } from "./navigation/utils.js";
@@ -130,7 +128,7 @@ export const MobileTopNavigation = () => {
130
128
  getProfileMenuItems,
131
129
  } = context;
132
130
  const headerNavigation = header?.navigation ?? [];
133
- const { isAuthenticated, profile, isAuthEnabled, isPending } = authState;
131
+ const { isAuthenticated, profile, isAuthEnabled } = authState;
134
132
  const [drawerOpen, setDrawerOpen] = useState(false);
135
133
 
136
134
  const accountItems = getProfileMenuItems();
@@ -195,9 +193,7 @@ export const MobileTopNavigation = () => {
195
193
  );
196
194
  })}
197
195
  {isAuthEnabled && isAuthenticated && (
198
- <ClientOnly
199
- fallback={<Skeleton className="rounded-sm h-5 w-24" />}
200
- >
196
+ <>
201
197
  <Separator className="my-2" />
202
198
  <li className="py-2">
203
199
  <div className="text-base font-medium">
@@ -226,45 +222,38 @@ export const MobileTopNavigation = () => {
226
222
  </Link>
227
223
  </li>
228
224
  ))}
229
- </ClientOnly>
225
+ </>
230
226
  )}
231
227
  </ul>
232
228
  </div>
233
229
  <div className="border-t shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] px-4 pt-3 flex flex-col gap-2">
234
230
  <div className="flex items-center justify-between">
235
- {isAuthEnabled && (
236
- <ClientOnly
237
- fallback={<Skeleton className="rounded-sm h-8 w-16" />}
238
- >
239
- {isPending ? (
240
- <Skeleton className="rounded-sm h-8 w-16" />
241
- ) : isAuthenticated ? (
242
- <Button asChild variant="outline">
243
- <Link
244
- to="/signout"
245
- onClick={() => setDrawerOpen(false)}
246
- className="flex items-center gap-2"
247
- >
248
- <LogOutIcon
249
- size={16}
250
- strokeWidth={1}
251
- absoluteStrokeWidth
252
- />
253
- Logout
254
- </Link>
255
- </Button>
256
- ) : (
257
- <Button asChild variant="outline">
258
- <Link
259
- to={`/signin?redirect=${encodeURIComponent(location.pathname)}`}
260
- onClick={() => setDrawerOpen(false)}
261
- >
262
- Login
263
- </Link>
264
- </Button>
265
- )}
266
- </ClientOnly>
267
- )}
231
+ {isAuthEnabled &&
232
+ (isAuthenticated ? (
233
+ <Button asChild variant="outline">
234
+ <Link
235
+ to="/signout"
236
+ onClick={() => setDrawerOpen(false)}
237
+ className="flex items-center gap-2"
238
+ >
239
+ <LogOutIcon
240
+ size={16}
241
+ strokeWidth={1}
242
+ absoluteStrokeWidth
243
+ />
244
+ Logout
245
+ </Link>
246
+ </Button>
247
+ ) : (
248
+ <Button asChild variant="outline">
249
+ <Link
250
+ to={`/signin?redirect=${encodeURIComponent(location.pathname)}`}
251
+ onClick={() => setDrawerOpen(false)}
252
+ >
253
+ Login
254
+ </Link>
255
+ </Button>
256
+ ))}
268
257
  <ThemeSwitch />
269
258
  </div>
270
259
  {site?.showPoweredBy !== false && (