zudoku 0.1.1-dev.13 → 0.1.1-dev.15

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.
Files changed (110) hide show
  1. package/dist/app/main.js +2 -2
  2. package/dist/app/main.js.map +1 -1
  3. package/dist/config/config.d.ts +0 -1
  4. package/dist/vite/build.js +2 -8
  5. package/dist/vite/build.js.map +1 -1
  6. package/dist/vite/config.d.ts +9 -3
  7. package/dist/vite/config.js +24 -22
  8. package/dist/vite/config.js.map +1 -1
  9. package/dist/vite/dev-server.js +6 -3
  10. package/dist/vite/dev-server.js.map +1 -1
  11. package/dist/vite/plugin-api.js +1 -1
  12. package/dist/vite/plugin-api.js.map +1 -1
  13. package/dist/vite/plugin-auth.js +1 -2
  14. package/dist/vite/plugin-auth.js.map +1 -1
  15. package/dist/vite/plugin-docs.js +1 -1
  16. package/dist/vite/plugin-docs.js.map +1 -1
  17. package/dist/vite/plugin-docs.test.js +0 -1
  18. package/dist/vite/plugin-docs.test.js.map +1 -1
  19. package/package.json +10 -5
  20. package/src/app/App.tsx +30 -0
  21. package/src/app/DevPortal.tsx +93 -0
  22. package/src/app/Heading.tsx +60 -0
  23. package/src/app/Router.tsx +28 -0
  24. package/src/app/authentication/authentication.ts +18 -0
  25. package/src/app/authentication/clerk.ts +47 -0
  26. package/src/app/authentication/openid.ts +192 -0
  27. package/src/app/components/AnchorLink.tsx +19 -0
  28. package/src/app/components/CategoryHeading.tsx +16 -0
  29. package/src/app/components/Dialog.tsx +119 -0
  30. package/src/app/components/DynamicIcon.tsx +60 -0
  31. package/src/app/components/Header.tsx +69 -0
  32. package/src/app/components/Input.tsx +24 -0
  33. package/src/app/components/Layout.tsx +56 -0
  34. package/src/app/components/Markdown.tsx +37 -0
  35. package/src/app/components/SyntaxHighlight.tsx +94 -0
  36. package/src/app/components/TopNavigation.tsx +32 -0
  37. package/src/app/components/context/ComponentsContext.tsx +24 -0
  38. package/src/app/components/context/DevPortalProvider.ts +54 -0
  39. package/src/app/components/context/PluginSystem.ts +0 -0
  40. package/src/app/components/context/ThemeContext.tsx +46 -0
  41. package/src/app/components/context/ViewportAnchorContext.tsx +139 -0
  42. package/src/app/components/navigation/SideNavigation.tsx +18 -0
  43. package/src/app/components/navigation/SideNavigationCategory.tsx +74 -0
  44. package/src/app/components/navigation/SideNavigationItem.tsx +143 -0
  45. package/src/app/components/navigation/SideNavigationWrapper.tsx +15 -0
  46. package/src/app/components/navigation/useNavigationCollapsibleState.ts +27 -0
  47. package/src/app/components/navigation/util.ts +38 -0
  48. package/src/app/core/DevPortalContext.ts +164 -0
  49. package/src/app/core/helmet.ts +5 -0
  50. package/src/app/core/icons.tsx +1 -0
  51. package/src/app/core/plugins.ts +43 -0
  52. package/src/app/core/router.tsx +1 -0
  53. package/src/app/core/types/combine.ts +16 -0
  54. package/src/app/main.css +137 -0
  55. package/src/app/main.tsx +9 -0
  56. package/src/app/oas/graphql/index.ts +422 -0
  57. package/src/app/oas/graphql/server.ts +10 -0
  58. package/src/app/oas/parser/dereference/index.ts +59 -0
  59. package/src/app/oas/parser/dereference/resolveRef.ts +32 -0
  60. package/src/app/oas/parser/index.ts +94 -0
  61. package/src/app/oas/parser/schemas/v3.0.json +1489 -0
  62. package/src/app/oas/parser/schemas/v3.1.json +1298 -0
  63. package/src/app/oas/parser/upgrade/index.ts +108 -0
  64. package/src/app/plugins/api-key/SettingsApiKeys.tsx +22 -0
  65. package/src/app/plugins/api-key/index.tsx +123 -0
  66. package/src/app/plugins/markdown/MdxPage.tsx +128 -0
  67. package/src/app/plugins/markdown/Toc.tsx +122 -0
  68. package/src/app/plugins/markdown/generateRoutes.tsx +72 -0
  69. package/src/app/plugins/markdown/index.tsx +31 -0
  70. package/src/app/plugins/openapi/ColorizedParam.tsx +82 -0
  71. package/src/app/plugins/openapi/MakeRequest.tsx +49 -0
  72. package/src/app/plugins/openapi/MethodBadge.tsx +36 -0
  73. package/src/app/plugins/openapi/OperationList.tsx +117 -0
  74. package/src/app/plugins/openapi/OperationListItem.tsx +55 -0
  75. package/src/app/plugins/openapi/ParameterList.tsx +32 -0
  76. package/src/app/plugins/openapi/ParameterListItem.tsx +60 -0
  77. package/src/app/plugins/openapi/RequestBodySidecarBox.tsx +51 -0
  78. package/src/app/plugins/openapi/ResponsesSidecarBox.tsx +60 -0
  79. package/src/app/plugins/openapi/Select.tsx +35 -0
  80. package/src/app/plugins/openapi/Sidecar.tsx +160 -0
  81. package/src/app/plugins/openapi/SidecarBox.tsx +36 -0
  82. package/src/app/plugins/openapi/graphql/fragment-masking.ts +111 -0
  83. package/src/app/plugins/openapi/graphql/gql.ts +70 -0
  84. package/src/app/plugins/openapi/graphql/graphql.ts +795 -0
  85. package/src/app/plugins/openapi/graphql/index.ts +2 -0
  86. package/src/app/plugins/openapi/index.tsx +142 -0
  87. package/src/app/plugins/openapi/playground/Playground.tsx +309 -0
  88. package/src/app/plugins/openapi/queries.graphql +6 -0
  89. package/src/app/plugins/openapi/util/generateSchemaExample.ts +59 -0
  90. package/src/app/plugins/openapi/util/urql.ts +8 -0
  91. package/src/app/plugins/openapi/worker/createSharedWorkerClient.ts +60 -0
  92. package/src/app/plugins/openapi/worker/worker.ts +30 -0
  93. package/src/app/plugins/redirect/index.tsx +20 -0
  94. package/src/app/tailwind.ts +72 -0
  95. package/src/app/ui/Button.tsx +56 -0
  96. package/src/app/ui/Callout.tsx +87 -0
  97. package/src/app/ui/Card.tsx +82 -0
  98. package/src/app/ui/Note.tsx +58 -0
  99. package/src/app/ui/Tabs.tsx +52 -0
  100. package/src/app/util/MdxComponents.tsx +70 -0
  101. package/src/app/util/cn.ts +6 -0
  102. package/src/app/util/createVariantComponent.tsx +30 -0
  103. package/src/app/util/createWaitForNotify.ts +18 -0
  104. package/src/app/util/groupBy.ts +24 -0
  105. package/src/app/util/joinPath.tsx +10 -0
  106. package/src/app/util/pastellize.ts +25 -0
  107. package/src/app/util/slugify.ts +3 -0
  108. package/src/app/util/traverseNavigation.ts +55 -0
  109. package/src/app/util/useScrollToAnchor.ts +38 -0
  110. package/src/app/util/useScrollToTop.ts +13 -0
@@ -0,0 +1,108 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import type { OpenAPIDocument } from "../index.js";
3
+ /**
4
+ * Upgrade from OpenAPI 3.0.x to 3.1.0
5
+ *
6
+ * Taken from https://github.com/scalar/openapi-parser/blob/main/packages/openapi-parser/src/utils/upgradeFromThreeToThreeOne.ts
7
+ * https://www.openapis.org/blog/2021/02/16/migrating-from-openapi-3-0-to-3-1-0
8
+ */
9
+ export function traverse(
10
+ specification: Record<string, any>,
11
+ transform: (specification: Record<string, any>) => Record<string, any>,
12
+ ) {
13
+ const result: Record<string, any> = {};
14
+
15
+ for (const [key, value] of Object.entries(specification)) {
16
+ if (Array.isArray(value)) {
17
+ result[key] = value.map((item) =>
18
+ typeof item === "object" && item !== null
19
+ ? traverse(item, transform)
20
+ : item,
21
+ );
22
+ } else if (typeof value === "object" && value !== null) {
23
+ result[key] = traverse(value, transform);
24
+ } else {
25
+ result[key] = value;
26
+ }
27
+ }
28
+
29
+ return transform(result);
30
+ }
31
+
32
+ export const upgradeSchema = (schema: Record<string, any>): OpenAPIDocument => {
33
+ if (schema.openapi?.startsWith("3.0")) {
34
+ schema.openapi = "3.1.0";
35
+ }
36
+
37
+ schema = traverse(schema, (sub) => {
38
+ if (sub.type !== "undefined" && sub.nullable === true) {
39
+ sub.type = ["null", sub.type];
40
+ delete sub.nullable;
41
+ }
42
+
43
+ return sub;
44
+ });
45
+
46
+ schema = traverse(schema, (sub) => {
47
+ if (sub.exclusiveMinimum === true) {
48
+ sub.exclusiveMinimum = sub.minimum;
49
+ delete sub.minimum;
50
+ } else if (sub.exclusiveMinimum === false) {
51
+ delete sub.exclusiveMinimum;
52
+ }
53
+
54
+ if (sub.exclusiveMaximum === true) {
55
+ sub.exclusiveMaximum = sub.maximum;
56
+ delete sub.maximum;
57
+ } else if (sub.exclusiveMaximum === false) {
58
+ delete sub.exclusiveMaximum;
59
+ }
60
+
61
+ return sub;
62
+ });
63
+
64
+ schema = traverse(schema, (sub) => {
65
+ if (sub.example !== undefined) {
66
+ sub.examples = {
67
+ default: sub.example,
68
+ };
69
+ delete sub.example;
70
+ }
71
+
72
+ return sub;
73
+ });
74
+
75
+ schema = traverse(schema, (sub) => {
76
+ if (sub.type === "object" && sub.properties !== undefined) {
77
+ for (const [, value] of Object.entries(sub.properties)) {
78
+ const v = (value ?? {}) as Record<string, any>;
79
+ if (v.type === "string" && v.format === "binary") {
80
+ v.contentEncoding = "application/octet-stream";
81
+ delete v.format;
82
+ }
83
+ }
84
+ }
85
+ return sub;
86
+ });
87
+
88
+ schema = traverse(schema, (sub) => {
89
+ if (sub.type === "string" && sub.format === "binary") {
90
+ return undefined as any;
91
+ }
92
+
93
+ return sub;
94
+ });
95
+
96
+ schema = traverse(schema, (sub) => {
97
+ if (sub.type === "string" && sub.format === "base64") {
98
+ return {
99
+ type: "string",
100
+ contentEncoding: "base64",
101
+ };
102
+ }
103
+
104
+ return sub;
105
+ });
106
+
107
+ return schema as OpenAPIDocument;
108
+ };
@@ -0,0 +1,22 @@
1
+ import { useApiIdentities } from "../../components/context/DevPortalProvider.js";
2
+ import { type ApiKeyPluginOptions } from "./index.js";
3
+
4
+ export const SettingsApiKeys = ({
5
+ options,
6
+ }: {
7
+ options: ApiKeyPluginOptions;
8
+ }) => {
9
+ const keys = useApiIdentities();
10
+
11
+ return (
12
+ <div className="prose dark:prose-invert">
13
+ <h1>Who's who</h1>
14
+ <button className="border">Create API Key</button>
15
+ <ul>
16
+ {keys.data.map((key) => (
17
+ <li>{key.name}</li>
18
+ ))}
19
+ </ul>
20
+ </div>
21
+ );
22
+ };
@@ -0,0 +1,123 @@
1
+ import { DevPortalSystemPaths } from "../../DevPortal.js";
2
+ import { DevPortalContext } from "../../core/DevPortalContext.js";
3
+ import {
4
+ type ApiIdentityPlugin,
5
+ type DevPortalPlugin,
6
+ } from "../../core/plugins.js";
7
+ import { SettingsApiKeys } from "./SettingsApiKeys.js";
8
+
9
+ export type GetConsumerOptions =
10
+ | {
11
+ getConsumers: (context: DevPortalContext) => Promise<ConsumerData | void>;
12
+ }
13
+ | { consumerEndpoint: string };
14
+
15
+ export type ApiKeyPluginOptions = {
16
+ rollKey?: (
17
+ id: string,
18
+ consumerName: string,
19
+ context: DevPortalContext,
20
+ ) => Promise<void>;
21
+ deleteKey?: (
22
+ id: string,
23
+ consumerName: string,
24
+ context: DevPortalContext,
25
+ ) => Promise<void>;
26
+ updateConsumerDescription?: (
27
+ consumerName: string,
28
+ description: string,
29
+ context: DevPortalContext,
30
+ ) => Promise<void>;
31
+ createConsumer?: (
32
+ description: string,
33
+ context: DevPortalContext,
34
+ ) => Promise<void>;
35
+ deleteConsumer?: (
36
+ consumerName: string,
37
+ context: DevPortalContext,
38
+ ) => Promise<void>;
39
+ } & GetConsumerOptions;
40
+
41
+ export interface ConsumerData {
42
+ consumers: Consumer[];
43
+ }
44
+
45
+ export interface Consumer {
46
+ name: string;
47
+ createdOn?: string;
48
+ description?: string;
49
+ apiKeys: ConsumerApiKey[];
50
+ }
51
+
52
+ export interface ConsumerApiKey {
53
+ id: string;
54
+ description?: string;
55
+ createdOn?: string;
56
+ updatedOn?: string;
57
+ expiresOn?: string;
58
+ key: string;
59
+ }
60
+
61
+ const defaultGetConsumers = async (context: DevPortalContext) => {
62
+ const accessToken = await context.authentication?.getToken?.(context);
63
+
64
+ if (!accessToken) {
65
+ throw new Error("No token found");
66
+ }
67
+
68
+ const consumers = await fetch(
69
+ "https://zudoku-customer-main-b36fa2f.d2.zuplo.dev/v1/developer/api-keys",
70
+ {
71
+ headers: {
72
+ Authorization: `Bearer ${accessToken}`,
73
+ },
74
+ },
75
+ );
76
+
77
+ return { consumers: [await consumers.json()] };
78
+ };
79
+
80
+ export const apiKeyPlugin = (
81
+ options: ApiKeyPluginOptions,
82
+ ): DevPortalPlugin & ApiIdentityPlugin => {
83
+ const getConsumers =
84
+ "getConsumers" in options ? options.getConsumers : defaultGetConsumers;
85
+
86
+ return {
87
+ getNavigation: async (path: string) => {
88
+ if (path !== DevPortalSystemPaths.Settings) {
89
+ return [];
90
+ }
91
+
92
+ return [
93
+ {
94
+ path: "settings/api-keys",
95
+ label: "API Keys",
96
+ children: [],
97
+ },
98
+ ];
99
+ },
100
+ getIdentities: async (context) => {
101
+ return [];
102
+ // throw new Error("Not implemented");
103
+ // const keys = await getConsumers(context);
104
+ // return (keys ?? { consumers: [] }).consumers
105
+ // .flatMap((consumer) => consumer.apiKeys)
106
+ // .map((key) => ({
107
+ // authorizeRequest: (request: Request) => {
108
+ // request.headers.set("Authorization", `Bearer ${key.key}`);
109
+ // },
110
+ // id: key.id,
111
+ // name: key.id,
112
+ // }));
113
+ },
114
+ getRoutes: () => {
115
+ return [
116
+ {
117
+ path: "/settings/api-keys",
118
+ element: <SettingsApiKeys options={options} />,
119
+ },
120
+ ];
121
+ },
122
+ };
123
+ };
@@ -0,0 +1,128 @@
1
+ import { useMemo, type PropsWithChildren, type ReactNode } from "react";
2
+ import { Heading } from "../../Heading.js";
3
+ import { CategoryHeading } from "../../components/CategoryHeading.js";
4
+ import { ProseClasses } from "../../components/Markdown.js";
5
+ import { useTopNavigationItem } from "../../components/context/DevPortalProvider.js";
6
+ import { isPathItem } from "../../components/navigation/util.js";
7
+ import { Helmet } from "../../core/helmet.js";
8
+ import { Link, useLocation } from "../../core/router.js";
9
+ import { cn } from "../../util/cn.js";
10
+ import slugify from "../../util/slugify.js";
11
+ import { traverseNavigation } from "../../util/traverseNavigation.js";
12
+ import { Toc } from "./Toc.js";
13
+ import type { MDXImport } from "./index.js";
14
+
15
+ export const MdxPage = ({
16
+ children,
17
+ frontmatter,
18
+ tableOfContents,
19
+ }: PropsWithChildren<Omit<MDXImport, "default">>) => {
20
+ const navItem = useTopNavigationItem();
21
+ const location = useLocation();
22
+
23
+ const categoryTitle = navItem
24
+ ? traverseNavigation(navItem, (_node, fullPath, parentNodes) => {
25
+ if (fullPath === location.pathname) {
26
+ return parentNodes.at(0)?.label;
27
+ }
28
+ })
29
+ : undefined;
30
+
31
+ const title = frontmatter?.title;
32
+ const category = frontmatter?.category ?? categoryTitle;
33
+ const hideToc = frontmatter?.toc === false;
34
+
35
+ const pageTitle =
36
+ tableOfContents.find((item) => item.depth === 1)?.value ?? title;
37
+
38
+ const tocEntries =
39
+ tableOfContents.find((item) => item.depth === 1)?.children ??
40
+ // if `title` is provided by frontmatter it does not appear in the table of contents
41
+ tableOfContents.filter((item) => item.depth === 2);
42
+
43
+ const showToc = !hideToc && tocEntries.length > 0;
44
+
45
+ const { prev, next } = useMemo(() => {
46
+ let prev = { path: "", label: "" as ReactNode };
47
+ let next = { path: "", label: "" as ReactNode };
48
+ let shouldStop = false;
49
+
50
+ if (!navItem) return { prev, next };
51
+
52
+ traverseNavigation(navItem, (node, fullPath) => {
53
+ const item = { path: fullPath, label: node.label };
54
+
55
+ if (shouldStop && isPathItem(node) && !node.children?.length) {
56
+ next = item;
57
+ return true;
58
+ }
59
+ if (fullPath === location.pathname) {
60
+ shouldStop = true;
61
+ }
62
+ if (!shouldStop && isPathItem(node) && !node.children?.length) {
63
+ prev = item;
64
+ }
65
+ });
66
+
67
+ return { prev, next } as const;
68
+ }, [navItem, location.pathname]);
69
+
70
+ return (
71
+ <div className="xl:grid grid-cols-[--sidecar-grid-cols] gap-8 justify-between">
72
+ <Helmet>
73
+ <title>{pageTitle}</title>
74
+ </Helmet>
75
+ <div
76
+ className={cn(
77
+ ProseClasses,
78
+ "mx-auto max-w-full xl:w-full xl:max-w-prose flex-1 flex-shrink pt-[--padding-content-top] pb-[--padding-content-bottom]",
79
+ )}
80
+ >
81
+ <header className="-translate-y-1">
82
+ {category && <CategoryHeading>{category}</CategoryHeading>}
83
+ {title && (
84
+ <Heading level={1} id={slugify(title, { lower: true })}>
85
+ {title}
86
+ </Heading>
87
+ )}
88
+ {frontmatter?.description && (
89
+ <p className="prose-lg">{frontmatter.description}</p>
90
+ )}
91
+ </header>
92
+ {children}
93
+ <hr />
94
+ <div className="not-prose flex items-center justify-between gap-8">
95
+ {prev.path ? (
96
+ <Link
97
+ to={prev.path}
98
+ className="flex flex-col items-stretch gap-2 flex-1 truncate border border-border rounded px-6 py-4 text-start hover:border-primary/85 transition shadow-sm hover:shadow-md"
99
+ title={typeof prev.label === "string" ? prev.label : undefined}
100
+ >
101
+ <div className="text-sm text-muted-foreground">
102
+ ← Previous page
103
+ </div>
104
+ <div className="text-lg text-primary truncate">{prev.label}</div>
105
+ </Link>
106
+ ) : (
107
+ <div className="flex-1" />
108
+ )}
109
+ {next.path ? (
110
+ <Link
111
+ to={next.path}
112
+ className="flex flex-col items-stretch gap-2 flex-1 truncate border border-border rounded px-6 py-4 text-end hover:border-primary/85 transition shadow-sm hover:shadow-md"
113
+ title={typeof next.label === "string" ? next.label : undefined}
114
+ >
115
+ <div className="text-sm text-muted-foreground">Next page →</div>
116
+ <div className="text-lg text-primary truncate">{next.label}</div>
117
+ </Link>
118
+ ) : (
119
+ <div className="flex-1" />
120
+ )}
121
+ </div>
122
+ </div>
123
+ <div className="hidden xl:block">
124
+ {showToc && <Toc entries={tocEntries} />}
125
+ </div>
126
+ </div>
127
+ );
128
+ };
@@ -0,0 +1,122 @@
1
+ import type { TocEntry } from "@stefanprobst/rehype-extract-toc";
2
+ import {
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type CSSProperties,
7
+ type PropsWithChildren,
8
+ } from "react";
9
+ import { AnchorLink } from "../../components/AnchorLink.js";
10
+ import { useViewportAnchor } from "../../components/context/ViewportAnchorContext.js";
11
+ import { ListTreeIcon } from "../../core/icons.js";
12
+ import { cn } from "../../util/cn.js";
13
+
14
+ const DATA_ANCHOR_ATTR = "data-active";
15
+
16
+ const TocItem = ({
17
+ item,
18
+ children,
19
+ className,
20
+ isActive,
21
+ }: PropsWithChildren<{
22
+ item: TocEntry;
23
+ isActive: boolean;
24
+ className?: string;
25
+ }>) => {
26
+ return (
27
+ <li
28
+ className={cn(
29
+ "truncate",
30
+ isActive
31
+ ? "text-primary"
32
+ : "text-foreground/65 dark:text-foreground/75",
33
+ className,
34
+ )}
35
+ title={item.value}
36
+ >
37
+ <AnchorLink
38
+ to={`#${item.id}`}
39
+ {...{ [DATA_ANCHOR_ATTR]: item.id }}
40
+ className={cn(
41
+ isActive
42
+ ? "text-primary"
43
+ : "text-foreground/65 dark:text-foreground/75 hover:text-foreground",
44
+ )}
45
+ >
46
+ {item.value}
47
+ </AnchorLink>
48
+ {children}
49
+ </li>
50
+ );
51
+ };
52
+
53
+ export const Toc = ({ entries }: { entries: TocEntry[] }) => {
54
+ const { activeAnchor } = useViewportAnchor();
55
+ const listWrapperRef = useRef<HTMLUListElement>(null);
56
+ const [indicatorStyle, setIndicatorStyles] = useState<CSSProperties>({});
57
+
58
+ // synchronize active anchor indicator with the scroll position
59
+ useEffect(() => {
60
+ if (!listWrapperRef.current) return;
61
+
62
+ const activeElement = listWrapperRef.current.querySelector(
63
+ `[${DATA_ANCHOR_ATTR}='${activeAnchor}']`,
64
+ );
65
+
66
+ if (!activeElement) {
67
+ setIndicatorStyles({
68
+ "--indicator-top": "0",
69
+ "--indicator-opacity": 0,
70
+ } as CSSProperties);
71
+ return;
72
+ }
73
+
74
+ const topParent = listWrapperRef.current.getBoundingClientRect().top;
75
+ const topElement = activeElement.getBoundingClientRect().top;
76
+
77
+ setIndicatorStyles({
78
+ "--indicator-top": `${topElement - topParent}px`,
79
+ "--indicator-opacity": 1,
80
+ } as CSSProperties);
81
+ }, [activeAnchor]);
82
+
83
+ return (
84
+ <aside className="sticky top-[--header-height] h-[calc(100vh-var(--header-height))] pt-[--padding-content-top] pb-[--padding-content-bottom] overflow-y-auto ps-1 text-sm">
85
+ <div className="flex items-center gap-2 font-medium mb-2">
86
+ <ListTreeIcon size={16} />
87
+ On this page
88
+ </div>
89
+
90
+ <ul
91
+ ref={listWrapperRef}
92
+ style={indicatorStyle}
93
+ className={cn(
94
+ "relative ms-2 ps-4 font-medium list-none mt-0 space-y-2",
95
+ "before:absolute before:inset-0 before:right-auto before:bg-border before:w-[2px]",
96
+ "after:absolute after:-left-px after:-translate-y-1 after:top-[--indicator-top] after:opacity-[--indicator-opacity] after:h-6 after:bg-primary after:w-[4px] after:rounded after:ease-out after:[transition:top__150ms,opacity_325ms]",
97
+ )}
98
+ >
99
+ {entries.map((item) => (
100
+ <TocItem
101
+ isActive={item.id === activeAnchor}
102
+ key={item.id}
103
+ item={item}
104
+ className="pl-0"
105
+ >
106
+ {item.children && (
107
+ <ul className="list-none pl-4 pt-2 space-y-2">
108
+ {item.children.map((child) => (
109
+ <TocItem
110
+ item={child}
111
+ isActive={child.id === activeAnchor}
112
+ key={child.id}
113
+ />
114
+ ))}
115
+ </ul>
116
+ )}
117
+ </TocItem>
118
+ ))}
119
+ </ul>
120
+ </aside>
121
+ );
122
+ };
@@ -0,0 +1,72 @@
1
+ import { Heading } from "../../Heading.js";
2
+ import { useTopNavigationItem } from "../../components/context/DevPortalProvider.js";
3
+ import { isPathItem } from "../../components/navigation/util.js";
4
+ import { Navigate, type RouteObject } from "../../core/router.js";
5
+ import type { MdxComponentsType } from "../../util/MdxComponents.js";
6
+ import { traverseNavigation } from "../../util/traverseNavigation.js";
7
+ import { MdxPage } from "./MdxPage.js";
8
+ import type { MarkdownPluginOptions } from "./index.js";
9
+
10
+ const MarkdownHeadings = {
11
+ h2: ({ children, id }) => (
12
+ <Heading level={2} id={id} children={children} registerSidebarAnchor />
13
+ ),
14
+ h3: ({ children, id }) => (
15
+ <Heading level={3} id={id} children={children} registerSidebarAnchor />
16
+ ),
17
+ } satisfies MdxComponentsType;
18
+
19
+ export const generateRoutes = (
20
+ markdownFiles: MarkdownPluginOptions["markdownFiles"],
21
+ ): RouteObject[] => {
22
+ const routes = Object.entries(markdownFiles).flatMap(
23
+ ([file, importPromise]) => {
24
+ // @todo we can pass in the folder name and then filter the markdown files based on that path
25
+ const match = file.match(/pages\/(.*).mdx?$/);
26
+ const path = match?.at(1);
27
+
28
+ if (!path) return [];
29
+
30
+ const pathSegments = path.split("/");
31
+ const isIndexFile = pathSegments.at(-1) === "index";
32
+ const routePath = isIndexFile
33
+ ? pathSegments.slice(0, -1).join("/")
34
+ : path;
35
+
36
+ return {
37
+ path: routePath,
38
+ lazy: async () => {
39
+ const { default: Component, ...props } = await importPromise();
40
+
41
+ return {
42
+ element: (
43
+ <MdxPage {...props}>
44
+ <Component components={MarkdownHeadings} />
45
+ </MdxPage>
46
+ ),
47
+ };
48
+ },
49
+ } satisfies RouteObject;
50
+ },
51
+ );
52
+
53
+ const rootRoutes = Array.from(
54
+ new Set(routes.map((route) => route.path.split("/").at(0))),
55
+ ).map((dir) => ({
56
+ path: `/${dir}`,
57
+ element: <Redirect />,
58
+ }));
59
+
60
+ return [...routes, ...rootRoutes];
61
+ };
62
+
63
+ const Redirect = () => {
64
+ const navItem = useTopNavigationItem();
65
+
66
+ if (!navItem) return null;
67
+
68
+ return traverseNavigation(navItem, (node, fullPath) => {
69
+ if ("children" in node || !isPathItem(node)) return;
70
+ return <Navigate to={fullPath} replace />;
71
+ });
72
+ };
@@ -0,0 +1,31 @@
1
+ import type { Toc } from "@stefanprobst/rehype-extract-toc";
2
+ import type { MDXProps } from "mdx/types.js";
3
+ import type { DevPortalPlugin } from "../../core/plugins.js";
4
+ import { generateRoutes } from "./generateRoutes.js";
5
+
6
+ export type MarkdownPluginOptions = {
7
+ markdownFiles: Record<string, () => Promise<MDXImport>>;
8
+ };
9
+
10
+ export type Frontmatter = {
11
+ title?: string;
12
+ description?: string;
13
+ category?: string;
14
+ toc?: boolean;
15
+ };
16
+
17
+ export type MDXImport = {
18
+ tableOfContents: Toc;
19
+ frontmatter: Frontmatter;
20
+ default: (props: MDXProps) => JSX.Element;
21
+ };
22
+
23
+ export const markdownPlugin = ({
24
+ markdownFiles,
25
+ }: MarkdownPluginOptions): DevPortalPlugin => {
26
+ return {
27
+ getRoutes() {
28
+ return generateRoutes(markdownFiles);
29
+ },
30
+ };
31
+ };
@@ -0,0 +1,82 @@
1
+ import { useEffect, useRef, type ReactNode } from "react";
2
+ import { useTheme } from "../../components/context/ThemeContext.js";
3
+ import { cn } from "../../util/cn.js";
4
+ import { pastellize } from "../../util/pastellize.js";
5
+
6
+ export const DATA_ATTR = "data-linked-param";
7
+
8
+ export const usePastellizedColor = (name: string) => {
9
+ const [isDark] = useTheme();
10
+ return pastellize(
11
+ name,
12
+ !isDark ? { saturation: 100, lightness: 70 } : undefined,
13
+ );
14
+ };
15
+
16
+ export const ColorizedParam = ({
17
+ name,
18
+ value,
19
+ className,
20
+ backgroundOpacity = "100%",
21
+ slug,
22
+ children,
23
+ onClick,
24
+ }: {
25
+ name: string;
26
+ value?: string;
27
+ className?: string;
28
+ backgroundOpacity?: string;
29
+ slug?: string;
30
+ children?: ReactNode;
31
+ onClick?: () => void;
32
+ }) => {
33
+ const ref = useRef<HTMLSpanElement>(null);
34
+ const normalized = name.replace("{", "").replace("}", "");
35
+ const color = usePastellizedColor(normalized);
36
+
37
+ const borderColor = `hsl(${color})`;
38
+ const backgroundColor = `hsl(${color} / ${backgroundOpacity})`;
39
+
40
+ useEffect(() => {
41
+ if (!slug) return;
42
+ if (!ref.current) return;
43
+
44
+ const onMouseEnter = () => {
45
+ document.querySelectorAll(`[${DATA_ATTR}="${slug}"]`).forEach((el) => {
46
+ if (el instanceof HTMLElement) {
47
+ el.dataset.active = "true";
48
+ }
49
+ });
50
+ };
51
+ const onMouseLeave = () => {
52
+ document.querySelectorAll(`[${DATA_ATTR}="${slug}"]`).forEach((el) => {
53
+ if (el instanceof HTMLElement) {
54
+ el.dataset.active = "false";
55
+ }
56
+ });
57
+ };
58
+
59
+ ref.current.addEventListener("mouseenter", onMouseEnter);
60
+ ref.current.addEventListener("mouseleave", onMouseLeave);
61
+
62
+ return () => {
63
+ ref.current?.removeEventListener("mouseenter", onMouseEnter);
64
+ ref.current?.removeEventListener("mouseleave", onMouseLeave);
65
+ };
66
+ }, [slug]);
67
+
68
+ return (
69
+ <span
70
+ className={cn("inline-flex relative rounded group", className)}
71
+ {...{ [DATA_ATTR]: slug }}
72
+ ref={ref}
73
+ onClick={onClick}
74
+ >
75
+ <span
76
+ className="absolute inset-0 border-b-2 transition-opacity duration-200 opacity-30 group-data-[active=true]:opacity-100"
77
+ style={{ borderColor, backgroundColor }}
78
+ />
79
+ <span className="relative">{children || name}</span>
80
+ </span>
81
+ );
82
+ };