Skip to content

Commit 336edde

Browse files
committed
simplify state management
1 parent 43595ba commit 336edde

File tree

11 files changed

+416
-267
lines changed

11 files changed

+416
-267
lines changed
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { HighlightedCode } from "codehike/code";
2+
import React from "react";
3+
import { SpeakeasyCodeSamplesCore } from "../core.js";
4+
import { codeSamplesGet } from "../funcs/codeSamplesGet.js";
5+
import { UsageSnippet } from "../models/components/usagesnippet.js";
6+
import { GetCodeSamplesRequest } from "../models/operations/getcodesamples.js";
7+
import { useSpeakeasyCodeSamplesContext } from "../react-query/_context.js";
8+
import { highlightCode } from "./utils.js";
9+
10+
export type SpeakeasyHighlightedCode = HighlightedCode & {
11+
/** The snippet data from the code samples api */
12+
raw: UsageSnippet;
13+
};
14+
15+
// Define the state shape.
16+
export type CodeSampleState =
17+
| {
18+
status: "loading";
19+
error?: Error | undefined;
20+
snippets?: SpeakeasyHighlightedCode[] | undefined;
21+
selectedSnippet?: SpeakeasyHighlightedCode | undefined;
22+
}
23+
| {
24+
status: "success";
25+
error?: Error | undefined;
26+
snippets: SpeakeasyHighlightedCode[];
27+
selectedSnippet: SpeakeasyHighlightedCode;
28+
}
29+
| {
30+
status: "error";
31+
error: Error;
32+
snippets?: SpeakeasyHighlightedCode[] | undefined;
33+
selectedSnippet?: SpeakeasyHighlightedCode | undefined;
34+
};
35+
36+
type FetchSuccessPayload = {
37+
snippets: SpeakeasyHighlightedCode[];
38+
defaultLanguage?: string | undefined;
39+
};
40+
41+
// Define the actions for our reducer.
42+
type Action =
43+
| { type: "FETCH_INIT" }
44+
| { type: "FETCH_SUCCESS"; payload: FetchSuccessPayload }
45+
| { type: "FETCH_FAILURE"; payload: Error }
46+
| { type: "SET_LANGUAGE"; payload: string };
47+
48+
function safeGetSnippetForLanguage(
49+
snippets: SpeakeasyHighlightedCode[],
50+
language?: string,
51+
): SpeakeasyHighlightedCode {
52+
if (!language) return snippets[0]!;
53+
54+
const selectedSnippet = snippets.find((s) => s.lang === language);
55+
if (selectedSnippet) {
56+
return selectedSnippet;
57+
}
58+
59+
console.warn(
60+
`Could not find snippet for language "${language}".`,
61+
`Falling back to to first language in snippet array.`,
62+
);
63+
64+
return snippets[0]!;
65+
}
66+
67+
const reducer: React.Reducer<CodeSampleState, Action> = (
68+
state: CodeSampleState,
69+
action: Action,
70+
) => {
71+
switch (action.type) {
72+
case "FETCH_INIT":
73+
return { ...state, status: "loading" };
74+
case "FETCH_SUCCESS":
75+
return {
76+
status: "success",
77+
snippets: action.payload.snippets,
78+
selectedSnippet: safeGetSnippetForLanguage(
79+
action.payload.snippets,
80+
action.payload.defaultLanguage,
81+
),
82+
};
83+
case "FETCH_FAILURE":
84+
return {
85+
...state,
86+
status: "error",
87+
error: action.payload,
88+
};
89+
case "SET_LANGUAGE":
90+
return {
91+
...state,
92+
selectedSnippet: safeGetSnippetForLanguage(
93+
state.snippets!,
94+
action.payload,
95+
),
96+
};
97+
default:
98+
return state;
99+
}
100+
};
101+
102+
type UseCodeSampleStateInit = {
103+
client?: SpeakeasyCodeSamplesCore | undefined;
104+
requestParams: GetCodeSamplesRequest;
105+
defaultLanguage?: string | undefined;
106+
};
107+
108+
export const useCodeSampleState = ({
109+
client: clientProp,
110+
requestParams,
111+
defaultLanguage,
112+
}: UseCodeSampleStateInit) => {
113+
const [state, dispatch] = React.useReducer(reducer, { status: "loading" });
114+
const client = useSafeSpeakeasyCodeSamplesContext(clientProp);
115+
116+
const highlightSnippets = async (snippets: UsageSnippet[]) => {
117+
return Promise.all(
118+
snippets.map(async (snippet) => {
119+
const highlightedCode = await highlightCode(
120+
snippet.code,
121+
snippet.language,
122+
"github-from-css",
123+
);
124+
125+
return { ...highlightedCode, raw: snippet };
126+
}),
127+
);
128+
};
129+
130+
async function handleMount() {
131+
dispatch({ type: "FETCH_INIT" });
132+
const result = await codeSamplesGet(client, requestParams);
133+
134+
if (!result.ok) {
135+
return dispatch({ type: "FETCH_FAILURE", payload: result.error });
136+
}
137+
138+
dispatch({
139+
type: "FETCH_SUCCESS",
140+
payload: {
141+
snippets: await highlightSnippets(result.value.snippets),
142+
defaultLanguage,
143+
},
144+
});
145+
}
146+
147+
React.useEffect(() => {
148+
handleMount();
149+
}, []);
150+
151+
function setSelectedLanguage(language: string) {
152+
dispatch({ type: "SET_LANGUAGE", payload: language });
153+
}
154+
155+
return { state, setSelectedLanguage };
156+
};
157+
158+
/** Intended to give the user the option of providing their own client. */
159+
export const useSafeSpeakeasyCodeSamplesContext = (
160+
coreClient?: SpeakeasyCodeSamplesCore,
161+
) => {
162+
if (coreClient) {
163+
return coreClient;
164+
}
165+
166+
try {
167+
const ctx = useSpeakeasyCodeSamplesContext();
168+
return ctx;
169+
} catch {
170+
throw new Error(
171+
"The Speakeasy Code Samples component must either be given an apiKey and " +
172+
"registryUrl, or be wrapped in a SpeakeasyCodeSamplesProvider.",
173+
);
174+
}
175+
};

src/react-components/code-sample.tsx

Lines changed: 53 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,20 @@
1-
import { Pre } from "codehike/code";
2-
import { useMemo } from "react";
3-
import { CopyButton } from "./copy-button.js";
1+
import { LazyMotion, domMax } from "motion/react";
2+
import React from "react";
3+
import { SpeakeasyCodeSamplesCore } from "../core.js";
44
import {
55
GetCodeSamplesRequest,
66
MethodPaths,
77
} from "../models/operations/getcodesamples.js";
8+
import { OperationId } from "../types/custom.js";
9+
import { useCodeSampleState } from "./code-sample.state.js";
810
import classes from "./code-sample.styles.js";
9-
import {
10-
useHighlightedCodeSamples,
11-
useSafeSpeakeasyCodeSamplesContext,
12-
useSelectedSnippet,
13-
} from "./hooks.js";
11+
import { CodeViewer, ErrorDisplay } from "./code-viewer.js";
12+
import codehikeTheme from "./codehike/theme.js";
13+
import { CopyButton } from "./copy-button.js";
1414
import { LanguageSelector } from "./language-selector.js";
15-
import { lineNumbers } from "./codehike/line-numbers.js";
16-
import {
17-
LanguageSelectorSkeleton,
18-
LoadingSkeleton,
19-
TitleSkeleton,
20-
} from "./skeleton.js";
21-
import { getCssVars, githubColorVars, useSystemColorMode } from "./styles.js";
22-
import { CodeSampleFilenameTitle, CodeSampleTitleComponent } from "./titles.js";
23-
import { tokenTransitions } from "./codehike/token-transitions.js";
24-
import { SpeakeasyCodeSamplesCore } from "../core.js";
25-
import { OperationId } from "../types/custom.js";
26-
import { LazyMotion, domAnimation } from "motion/react";
15+
import { LanguageSelectorSkeleton, LoadingSkeleton } from "./skeleton.js";
16+
import { getCssVars, useSystemColorMode } from "./styles.js";
17+
import { type CodeSampleTitleComponent, CodeSampleTitle } from "./titles.js";
2718

2819
export type CodeSamplesViewerProps = {
2920
/** Whether the code snippet should be copyable. */
@@ -46,7 +37,7 @@ export type CodeSamplesViewerProps = {
4637
* @see CodeSampleFilenameTitle
4738
* @default CodeSampleMethodTitle
4839
*/
49-
title?: CodeSampleTitleComponent;
40+
title?: CodeSampleTitleComponent | React.ReactNode | string;
5041
/** The operation to get a code sample for. Can be queried by either
5142
* operationId or method+path.
5243
*/
@@ -67,45 +58,38 @@ export function CodeSamplesViewer({
6758
operation,
6859
style,
6960
copyable,
70-
defaultLang,
7161
client: clientProp,
7262
}: CodeSamplesViewerProps) {
73-
const TitleComponent = title;
74-
75-
const systemColorMode = useSystemColorMode();
76-
77-
const codeTheme = useMemo(() => {
78-
if (theme === "system") return githubColorVars[systemColorMode];
79-
return githubColorVars[theme];
80-
}, [theme, systemColorMode]);
81-
82-
const request: GetCodeSamplesRequest = useMemo(() => {
63+
const request: GetCodeSamplesRequest = React.useMemo(() => {
8364
if (typeof operation === "string") return { operationIds: [operation] };
8465
return { methoPaths: [operation] };
8566
}, [operation]);
8667

87-
const client = useSafeSpeakeasyCodeSamplesContext(clientProp);
88-
const { status, data, error } = useHighlightedCodeSamples(client, request);
68+
const { state, setSelectedLanguage } = useCodeSampleState({
69+
client: clientProp,
70+
requestParams: request,
71+
});
8972

90-
const { selectedSnippet, selectedLang, setSelectedLang } = useSelectedSnippet(
91-
data,
92-
defaultLang,
93-
);
73+
const systemColorMode = useSystemColorMode();
74+
const codeTheme = React.useMemo(() => {
75+
if (theme === "system") return codehikeTheme[systemColorMode];
76+
return codehikeTheme[theme];
77+
}, [theme, systemColorMode]);
9478

95-
const longestCodeHeight = useMemo(() => {
79+
const longestCodeHeight = React.useMemo(() => {
9680
const largestLines = Math.max(
97-
...Object.values(data ?? [])
81+
...Object.values(state.snippets ?? [])
9882
.filter((snippet) => snippet.code !== undefined)
9983
.map((code) => code.code!.split("\n").length),
10084
);
10185

10286
const lineHeight = 23;
10387
const padding = 12;
10488
return largestLines * lineHeight + padding * 2;
105-
}, [data]);
89+
}, [state.snippets]);
10690

10791
return (
108-
<LazyMotion strict features={domAnimation}>
92+
<LazyMotion strict features={domMax}>
10993
<div
11094
style={{
11195
...codeTheme,
@@ -117,39 +101,37 @@ export function CodeSamplesViewer({
117101
className={`${classes.root} ${className ?? ""}`}
118102
>
119103
<div className={classes.heading}>
120-
{status === "loading" && error === undefined ? (
121-
<TitleSkeleton />
122-
) : TitleComponent && selectedSnippet ? (
123-
<TitleComponent {...selectedSnippet!.raw} />
124-
) : (
125-
<CodeSampleFilenameTitle {...selectedSnippet!.raw} />
104+
<CodeSampleTitle
105+
component={title}
106+
status={state.status}
107+
data={state.selectedSnippet?.raw}
108+
/>
109+
<>
110+
{state.status === "loading" && <LanguageSelectorSkeleton />}
111+
{state.status === "success" && (
112+
<LanguageSelector
113+
value={state.selectedSnippet?.lang}
114+
onChange={setSelectedLanguage}
115+
snippets={state.snippets ?? []}
116+
className={classes.selector}
117+
/>
118+
)}
119+
</>
120+
</div>
121+
<div className={classes.codeContainer}>
122+
{state.status === "success" && copyable && (
123+
<CopyButton code={state.selectedSnippet.code} />
126124
)}
127-
{status === "loading" && error === undefined ? (
128-
<LanguageSelectorSkeleton />
129-
) : (
130-
<LanguageSelector
131-
value={selectedLang}
132-
onChange={setSelectedLang}
133-
snippets={data ?? []}
134-
className={classes.selector}
125+
{state.status === "loading" && <LoadingSkeleton />}
126+
{state.status === "error" && <ErrorDisplay error={state.error} />}
127+
{state.status === "success" && (
128+
<CodeViewer
129+
status={state.status}
130+
code={state.selectedSnippet}
131+
longestCodeHeight={longestCodeHeight}
135132
/>
136133
)}
137134
</div>
138-
<div className={classes.codeContainer}>
139-
{status === "loading" ? (
140-
<LoadingSkeleton />
141-
) : selectedSnippet ? (
142-
<>
143-
{copyable && <CopyButton code={selectedSnippet.code} />}
144-
<Pre
145-
className={classes.pre}
146-
style={{ height: longestCodeHeight }}
147-
handlers={[lineNumbers, tokenTransitions]}
148-
code={selectedSnippet}
149-
/>
150-
</>
151-
) : null}
152-
</div>
153135
</div>
154136
</LazyMotion>
155137
);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { css } from "@emotion/css";
2+
import { cssVarKey, fontSize, fontWeight, spacing } from "./styles.js";
3+
4+
export const classes = {
5+
pre: css({
6+
margin: 0,
7+
padding: `${spacing[3]} 0`,
8+
}),
9+
errorContainer: css({
10+
display: "flex",
11+
flexDirection: "column",
12+
justifyContent: "center",
13+
padding: spacing[4],
14+
minHeight: "200px",
15+
}),
16+
errorTitle: css({
17+
fontSize: fontSize.lg,
18+
fontWeight: fontWeight.semibold,
19+
margin: 0,
20+
}),
21+
errorMessage: css({
22+
color: `var(${cssVarKey.foregroundError})`,
23+
fontSize: fontSize.sm,
24+
margin: 0,
25+
}),
26+
};

0 commit comments

Comments
 (0)