Initial Commit

This commit is contained in:
Luke Hagar
2024-01-01 15:47:37 -06:00
parent 203c48ae7c
commit f91c1e6f54
4519 changed files with 88021 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
import React, { FC, useCallback, useEffect, useState } from 'react';
import CheckIcon from '@/src/icons/CheckIcon';
import CopyIcon from '@/src/icons/CopyIcon';
import styles from './styles.module.scss';
interface ICopyToClipboard {
getValue(): string;
}
const CopyToClipboard: FC<ICopyToClipboard> = ({ getValue }) => {
const [isCopied, setCopied] = useState<boolean>(false);
const handleClick = useCallback(async () => {
setCopied(true);
if (!navigator?.clipboard) {
console.error('Access to clipboard rejected!');
}
try {
await navigator.clipboard.writeText(getValue());
} catch {
console.error('Failed to copy!');
}
}, [getValue]);
const IconToUse = isCopied ? CheckIcon : CopyIcon;
useEffect(() => {
if (!isCopied) {
return;
}
const timerId = setTimeout(() => {
setCopied(false);
}, 2000);
return () => {
clearTimeout(timerId);
};
}, [isCopied]);
return (
<button type='button' className={styles.button} onClick={handleClick}>
<IconToUse />
</button>
);
};
export default CopyToClipboard;

View File

@@ -0,0 +1,23 @@
@import '@/src/styles/utils/mixins';
.button {
@include flex(row, center, center);
@include generatePxToRem(padding, 8);
@include generatePxToRem(border-radius, 8);
background-color: var(--code-background) !important;
border: 1px solid var(--code-block-border);
cursor: pointer;
svg path {
stroke: var(--text-heading);
stroke-opacity: var(--stroke-opacity);
}
&:hover {
border-color: $select-hover;
svg path:nth-of-type(1) {
stroke: url(#paint0_linear_459_12127);
}
svg path:nth-of-type(2) {
stroke: url(#paint1_linear_459_12127);
}
}
}

View File

@@ -0,0 +1,142 @@
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import RightArrow from '@/src/icons/RightArrow';
import styles from './styles.module.scss';
export type Props = {
openLabel: string;
closeLabel: string;
children: ReactNode[];
defaultOpen?: boolean;
content?: () => Promise<any>;
};
const CollapsibleContext = createContext({
isOpen: true,
});
const Collapsible = (props: Props) => {
const headerDefaultHeight = 36;
const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false);
const [height, setHeight] = useState(headerDefaultHeight);
const [ContentComponent, setContentComponent] = useState<any>(null);
const [shouldTransitionHeight, setShouldTransitionHeight] = useState(false);
const parentContext = useContext(CollapsibleContext);
const [headerRef, headerHeight] = useRefWithHeight();
const [bodyRef, bodyHeight] = useRefWithHeight();
const heading = headingText(props.openLabel, props.closeLabel, isOpen);
const updateOpenHeight = (shouldTransition: boolean) => {
setHeight((headerHeight || headerDefaultHeight) + (bodyHeight || 0));
setShouldTransitionHeight(shouldTransition);
};
const open = () => {
if (!isOpen) {
updateOpenHeight(true);
} else {
setShouldTransitionHeight(true);
setHeight(headerHeight);
}
setIsOpen((prev) => !prev);
};
useEffect(() => {
if (isOpen) {
updateOpenHeight(false);
}
}, [bodyHeight]);
/*
* Pre-load dynamic content when the parent is opened
*/
useEffect(() => {
if (parentContext.isOpen && props.content && !ContentComponent) {
props
.content()
.then((module) => {
setContentComponent(() => module.default);
})
.catch((error) => {
console.error('Failed to load the content component', error);
});
}
}, [parentContext.isOpen, props.content, ContentComponent]);
const dynamicChildren = useMemo(
() =>
ContentComponent
? [<ContentComponent key='dynamicContentComponent' />]
: [],
[ContentComponent],
);
const existingChildren = props.children ? props.children : [];
const children =
dynamicChildren.length > 0
? [...existingChildren, ...dynamicChildren]
: existingChildren;
return (
<CollapsibleContext.Provider value={{ isOpen }}>
<div
className={styles.collapsible}
style={{
height,
...(shouldTransitionHeight && { transition: 'height 0.5s ease' }),
}}
>
<div
ref={headerRef}
onClick={open}
className={styles.collapsible_heading}
>
<RightArrow activeClass={isOpen ? 'active' : ''} />
<h5>{heading}</h5>
</div>
<div ref={bodyRef} className={styles.collapsible_body}>
{children}
</div>
</div>
</CollapsibleContext.Provider>
);
};
const useRefWithHeight = (): [(ref: HTMLDivElement) => void, number] => {
const [height, setHeight] = useState(0);
const ref = useCallback((node: HTMLDivElement) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setHeight(entry.target.getBoundingClientRect().height);
}
});
resizeObserver.observe(node);
}
}, []);
return [ref, height];
};
const headingText = (openLabel: string, closeLabel: string, isOpen: boolean) =>
isOpen ? closeLabel : openLabel;
export default Collapsible;

View File

@@ -0,0 +1,62 @@
@import '../../styles/utils/mixins';
.collapsible {
border-radius: var(--base-border-radius);
border: 1px solid var(--hr-color);
backdrop-filter: blur(10px);
@include generatePxToRem('margin', 10 0);
padding: 0;
overflow: hidden;
// Make the breaks extend edge-to-edge
hr {
width: 120%;
@include generatePxToRem('margin', 12 -12 16 -12, !important);
}
&_heading {
@include flex(row, initial, center, nowrap);
transition: all 0.5s ease;
@include generatePxToRem('padding', 8);
> h5 {
@include collapse-children-heading-text();
@include generatePxToRem('margin', 0 0 0 10, !important);
font-family: var(--font-family-mono);
font-weight: normal;
@include generatePxToRem('font-size', 14);
}
&:hover {
cursor: pointer;
> h5 {
color: var(--text-emphasis);
}
path {
stroke: var(--text-emphasis);
}
}
}
&_body {
@include generatePxToRem('padding', 12);
border-top: 1px solid var(--hr-color);
table {
@include generatePxToRem(margin, -12, !important);
}
> p:not(:last-child) {
@include generatePxToRem('margin', 10 0 10 0, !important);
}
& > *:last-child {
@include desktopMin1400 {
margin-bottom: 3px;
}
}
}
}

View File

@@ -0,0 +1,32 @@
import React, { FC, ReactNode } from 'react';
import { splitByType } from '@/src/components/typeHelpers';
import styles from './styles.module.scss';
interface IColumns {
children: ReactNode;
}
export const Columns: FC<IColumns> & { RHS: typeof RHS } = ({ children }) => {
const [rhs = [], lhs = []] = splitByType(children, RHS);
const mainContent = lhs.length || rhs.length ? lhs : children;
const columns = (
<div className={styles.container}>
<div className={styles.columnContainer}>
<div className={styles.mainContent}>{mainContent}</div>
{/* Extra level of nesting needed for sticky to work */}
<div>
<div className={styles.rightSideContent}>{rhs}</div>
</div>
</div>
</div>
);
return <div>{columns}</div>;
};
export const RHS = (props: { children: ReactNode }) => <>{props.children}</>;
Columns.RHS = RHS;

View File

@@ -0,0 +1,46 @@
@import '@/src/styles/utils/mixins';
.container {
display: flex;
justify-content: center;
}
.headings {
@include tabletsMax768 {
max-width: 100%;
}
}
.columnContainer {
width: 100%;
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 80px;
@include desktopMax1400 {
grid-auto-flow: row;
gap: 0;
}
.mainContent {
& > *:last-child {
@include desktopMin1400 {
margin-bottom: 0;
}
}
}
.rightSideContent {
position: sticky;
top: 100px;
@include desktopMax1400 {
@include generatePxToRem(max-width, 688);
margin-left: 0;
}
@include tabletsMax768 {
max-width: 100%;
}
}
}

View File

@@ -0,0 +1,27 @@
import React, { FC } from 'react';
import NextLink from 'next/link';
import Logo from '@/src/components/Logo';
import styles from './styles.module.scss';
const Footer: FC = () => (
<footer className={styles.footer}>
<Logo />
<div className={styles.footer_links}>
<div className={styles.footer_links_social}>
<p className={styles.footer_links_social_inc}>
<NextLink
href={'https://www.speakeasyapi.dev/'}
key={'speakeasy'}
target='_blank'
>
Built by Speakeasy
</NextLink>
</p>
</div>
</div>
</footer>
);
export default Footer;

View File

@@ -0,0 +1,72 @@
@import '@/src/styles/utils/mixins';
.footer {
width: 100%;
position: relative;
@include flex(column, space-between, flex-start);
gap: 40px;
padding: 88px 128px;
background-color: var(--color-footer-background);
@media (max-width: 1130px) {
align-items: center;
}
@include tabletsMax768 {
padding: 48px 24px;
}
&_links {
width: 100%;
@include flex(row, space-between, initial, wrap);
gap: 20px;
z-index: 2;
@media (max-width: 1130px) {
gap: 80px;
flex-direction: column;
align-items: center;
}
@include mobile {
align-items: flex-start;
}
&_general {
@include flex(row, flex-start, flex-end);
gap: 32px;
@media (max-width: 1130px) {
align-items: center;
}
@include mobile {
flex-direction: column;
align-items: flex-start;
}
&_link {
@include footer-links-text();
}
}
&_social {
@include flex(column, flex-end, flex-end, nowrap);
gap: 32px;
@media (max-width: 1130px) {
width: 100%;
align-items: center;
}
&_icons {
display: flex;
gap: 24px;
}
&_inc {
@include footer-inc-text();
}
}
}
}

View File

@@ -0,0 +1,24 @@
import React, { FC } from 'react';
import NextLink from 'next/link';
import Logo from '@/src/components/Logo';
import ThemeToggle from '@/src/components/ThemeToggle';
import styles from './styles.module.scss';
const Header: FC = () => (
<div className={styles.headerRoot}>
<div className={styles.headerInner}>
<div>
<NextLink href='/'>
<Logo />
</NextLink>
</div>
<div>
<ThemeToggle />
</div>
</div>
</div>
);
export default Header;

View File

@@ -0,0 +1,18 @@
@import '@/src/styles/utils/mixins';
.headerRoot {
width: 100%;
height: 96px;
@include generatePxToRem(padding, 0 32 0 32);
position: sticky;
top: 0;
z-index: 10;
background-color: var(--color-page-background);
.headerInner {
@include flex(row, space-between, center);
height: inherit;
div {
@include flex(row, center, center);
}
}
}

View File

@@ -0,0 +1,83 @@
import cn from 'classnames';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { languageData } from '@/src/lib/languageData';
import { LanguageContext } from '@/src/utils/contexts/languageContext';
import styles from './styles.module.scss';
const LanguageSelector = ({ showIcon }: { showIcon?: boolean }) => {
const {
language: currentLanguage,
setLanguage,
languages,
} = useContext(LanguageContext);
const [isOpen, setIsOpen] = useState(false);
const selectLanguage = useCallback(
(language: string) => {
setLanguage(language);
setIsOpen(false);
},
[setLanguage],
);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) {
return;
}
const onMouseDown = (event: MouseEvent) => {
if (
containerRef.current != null &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', onMouseDown);
return () => document.removeEventListener('mousedown', onMouseDown);
}, [containerRef, isOpen]);
return (
<div
className={cn(styles.languageSelector, {
[styles.open]: isOpen,
[styles.hasIcon]: showIcon,
})}
ref={containerRef}
>
<div className={styles.button} onClick={() => setIsOpen(!isOpen)}>
{showIcon ? (
<div className={styles.icon}>
{languageData[currentLanguage].Icon({ style: 'outline' })}
</div>
) : null}
<div className={styles.language}>
{languageData[currentLanguage]?.title}
</div>
<div className={styles.popUpButton} />
</div>
{isOpen ? (
<div className={styles.options}>
<ul>
{languages.map((language) => (
<li
key={language}
className={cn({
[styles.active]: language === currentLanguage,
})}
onClick={() => selectLanguage(language)}
>
{languageData[language].title}
</li>
))}
</ul>
</div>
) : null}
</div>
);
};
export default LanguageSelector;

View File

@@ -0,0 +1,97 @@
@import '@/src/styles/utils/mixins';
.languageSelector {
position: relative;
.button {
height: 36px;
@include flex(row, center, center);
border-radius: var(--base-border-radius);
cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0);
}
&.open .button, .button:hover {
border: 1px solid var(--hr-color);
.language {
opacity: 1;
}
.popUpButton {
opacity: 1;
}
}
.icon {
@include generatePxToRem(padding, 8);
}
.language {
@include generatePxToRem(padding-right, 8);
@include generatePxToRem(font-size, 14);
font-weight: 600;
}
&:not(.hasIcon) .language {
@include generatePxToRem(padding-left, 8);
}
.popUpButton {
width: 12px;
height: 13px;
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 12 13' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.49451 1.30869L6 0.875L5.50548 1.30869L1.44053 4.87365L0.881031 5.36432H3.15598L6 2.87013L8.84402 5.36432H11.119L10.5595 4.87365L6.49451 1.30869Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M5.50549 12.4413L6 12.875L6.49452 12.4413L10.5595 8.87635L11.119 8.38568L8.84402 8.38568L6 10.8799L3.15598 8.38568L0.881034 8.38568L1.44053 8.87635L5.50549 12.4413Z' fill='white'/%3E%3C/svg%3E%0A");
mask-repeat: no-repeat;
background-color: var(--lang-selector-primary-color);
opacity: 0.4;
@include generatePxToRem(margin-right, 10);
}
svg {
width: 24px;
height: 24px;
}
svg path {
fill: var(--lang-selector-icon-color);
}
.options {
position: absolute;
top: 36px;
left: 0;
@include generatePxToRem(margin-top, 8);
border-radius: var(--base-border-radius);
border: 1px solid var(--hr-color);
min-width: 8rem;
z-index: 1000;
background-color: var(--color-page-background);
li {
@include generatePxToRem(padding, 8 12 8 16);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
@include generatePxToRem(font-size, 14);
font-weight: 600;
letter-spacing: 0.01rem;
color: var(--lang-selector-item-color);
&:first-child {
border-radius: var(--base-border-radius) var(--base-border-radius) 0 0;
}
&:last-child {
border-radius: 0 0 var(--base-border-radius) var(--base-border-radius);
}
&.active {
color: var(--primary-color);
}
&:hover {
background-color: var(--lang-selector-item-hover-background-color);
}
}
}
}

View File

@@ -0,0 +1,36 @@
import React, { FC, ReactNode, useContext } from 'react';
import Link from 'next/link';
import ExternalLink from '@/src/icons/ExternalLink';
import { checkIsLinkInternal } from '@/src/utils/helpers';
import styles from './styles.module.scss';
import { ScrollContext } from '../scrollManager';
interface ILinkWrapper {
children: ReactNode;
href: string;
}
const LinkWrapper: FC<ILinkWrapper> = ({ children, href = '/' }) => {
const { scrollTo } = useContext(ScrollContext);
const isInternalLink = checkIsLinkInternal(href);
const handleInternalClick = (e: any) => {
e.preventDefault();
scrollTo(href);
};
return isInternalLink ? (
<Link href={href} className={styles.link} onClick={handleInternalClick}>
{children}
</Link>
) : (
<a className={styles.link} href={href} target={'_blank'}>
{children}
<ExternalLink />
</a>
);
};
export default LinkWrapper;

View File

@@ -0,0 +1,30 @@
@import '@/src/styles/utils/mixins';
.link {
display: inline-flex;
align-items: center;
text-decoration: none;
font-style: normal;
font-weight: bold;
line-height: 152%;
border-bottom: 1px solid var(--text-link);
border-radius: 0;
margin-bottom: 1px;
column-gap: 4px;
svg {
height: 1em;
width: 1em;
padding-bottom: 2px;
stroke: currentColor;
}
&:hover {
border-bottom: 2px solid var(--text-link);
margin-bottom: 0;
filter: brightness(1.1);
}
}

View File

@@ -0,0 +1,47 @@
import React, { createRef, useEffect } from 'react';
import Image from 'next/image';
import { useTheme } from 'next-themes';
import docsTheme from '@/src/utils/theme';
const Logo = () => {
const { resolvedTheme } = useTheme();
const darkSourceRef = createRef<HTMLSourceElement>();
// This is necessary because of how Next handles images
useEffect(() => {
if (darkSourceRef.current) {
darkSourceRef.current.media = resolvedTheme === 'dark' ? 'all' : 'none';
}
}, [resolvedTheme, darkSourceRef.current]);
const logo = (
<picture>
<source
ref={darkSourceRef}
srcSet={docsTheme.logo.dark}
media='(prefers-color-scheme: dark)'
/>
<Image
src={docsTheme.logo.light}
alt='Logo'
width={125}
height={50}
style={{ height: 'auto' }}
/>
</picture>
);
return (
<a
href={docsTheme.logo.link}
target={'_blank'}
onClick={(e) => e.stopPropagation()}
>
{logo}
</a>
);
};
export default Logo;

View File

@@ -0,0 +1,13 @@
@import '@/src/styles/utils/mixins';
.logo {
@include mobile {
@include generatePxToRem(width, 124);
@include generatePxToRem(height, 24);
}
.path {
fill: var(--text-heading);
fill-opacity: var(--text-heading-opacity);
}
}

View File

@@ -0,0 +1,14 @@
import React, { FC } from 'react';
import InfoIcon from '@/src/icons/InfoIcon';
import styles from './styles.module.scss';
const Message: FC<{ message: string }> = ({ message }) => (
<div className={styles.message}>
<InfoIcon />
<p>{message}</p>
</div>
);
export default Message;

View File

@@ -0,0 +1,17 @@
@import '@/src/styles/utils/mixins';
.message {
width: 100%;
@include flex(row, initial, center, nowrap);
@include generatePxToRem(padding, 16 24);
@include generatePxToRem(gap, 10);
@include generatePxToRem(border-radius, 16);
border: 1px solid var(--code-block-border);
background: var(--message-bcg-color);
backdrop-filter: blur(10px);
> p {
margin: 0;
@include message-text();
}
}

View File

@@ -0,0 +1,9 @@
import { FC } from 'react';
import styles from './styles.module.scss';
const MethodPill: FC<{
method: string;
}> = ({ method }) => <span className={styles.methodPill}>{method}</span>;
export default MethodPill;

View File

@@ -0,0 +1,15 @@
@import '@/src/styles/utils/mixins';
.methodPill {
background-color: var(--method-pill-bg-color);
font-family: var(--font-family-mono);
letter-spacing: 0.03rem;
padding: 3px 9px 3px 8px;
font-size: 85%;
font-weight: 500;
text-transform: uppercase;
border-radius: 4px;
margin-bottom: 20px;
display: inline-block;
@include generatePxToRem('height', 26);
}

View File

@@ -0,0 +1,27 @@
import React, { FC, useContext } from 'react';
import cn from 'classnames';
import styles from './styles.module.scss';
import { ScrollContext } from '../scrollManager';
export const NavItem: FC<Record<string, string>> = ({ route, title, type }) => {
const { scrollTo } = useContext(ScrollContext);
const classForItem = {
[styles['separator']]: type === 'separator',
};
if (route === '/') {
return null;
}
const handleClick = (e: any) => {
e.stopPropagation();
scrollTo(route);
};
return (
<div onClick={handleClick} className={cn(styles.nav_item, classForItem)}>
<p>{title}</p>
</div>
);
};

View File

@@ -0,0 +1,46 @@
@import '@/src/styles/utils/mixins';
.nav_item {
width: 100%;
@include generatePxToRem('padding', 8);
@include generatePxToRem('border-radius', 8);
border: 2px solid transparent;
transition: all 0.15s ease;
&:hover {
@include generatePxToRem('border-radius', 8);
border: 2px solid var(--heading-sidebar-bg-color);
}
&.separator {
@include generatePxToRem('padding', 14 16 0 4);
&:hover {
border: 2px solid transparent;
}
> p {
@include sidebar-heading-separator(var(--text-heading));
text-transform: uppercase;
}
}
&.selected {
width: 100%;
// Check /src/styles/nextra.scss for the rest of the styling
}
&.visible {
background-color: #2A2A2A;
}
> p {
margin: 0;
@include heading-h5(var(--text-subheading));
&:first-letter {
text-transform: uppercase;
}
}
}

View File

@@ -0,0 +1,17 @@
import { FC, useContext } from 'react';
import { LanguageContext } from '@/src/utils/contexts/languageContext';
const OperationHeader: FC<{
sdkHeader: React.ReactNode;
curlHeader: React.ReactNode;
}> = ({ sdkHeader, curlHeader }) => {
const { language } = useContext(LanguageContext);
if (language == 'curl') {
return curlHeader;
} else {
return sdkHeader;
}
};
export default OperationHeader;

View File

@@ -0,0 +1,63 @@
import cn from 'classnames';
import styles from './styles.module.scss';
import MethodPill from '../MethodPill';
const OperationInfo = ({ method, path }: { method: string; path: string }) => (
<div className={styles.operationInfo}>
<MethodPill method={method} />
<div className={styles.path}>
{pathComponents(path).map((component, index) => {
const { type } = component;
return (
<span
key={index}
className={cn(
type === 'placeholder' ? styles.placeholder : null,
type === 'separator' ? styles.separator : null,
)}
>
{text(component)}
</span>
);
})}
</div>
</div>
);
export default OperationInfo;
type PathComponent =
| { type: 'placeholder'; value: string }
| { type: 'separator' }
| { type: 'text'; value: string };
const pathComponents = (path: string): PathComponent[] => {
const components = path.split(/(\/)/g);
const pathComponents: PathComponent[] = [];
components.forEach((component) => {
if (component === '/') {
pathComponents.push({ type: 'separator' });
} else if (component.startsWith('{') && component.endsWith('}')) {
pathComponents.push({ type: 'placeholder', value: component });
} else {
pathComponents.push({ type: 'text', value: component });
}
});
return pathComponents;
};
const text = (pathComponent: PathComponent) => {
const { type } = pathComponent;
switch (type) {
case 'placeholder':
case 'text':
return pathComponent.value;
case 'separator':
return '/';
}
};

View File

@@ -0,0 +1,29 @@
@import '@/src/styles/utils/mixins';
.operationInfo {
display: flex;
flex-direction: row;
margin-top: -1rem;
margin-bottom: 2rem;
.path {
@include generatePxToRem(margin-left, 12);
@include generatePxToRem(font-size, 16);
line-height: 1.650;
font-family: var(--font-family-mono);
.placeholder {
color: var(--primary-color);
}
.separator {
opacity: 0.3;
font-weight: 600;
@include generatePxToRem(padding, 0 2);
}
span {
display: inline-block;
}
}
}

View File

@@ -0,0 +1,40 @@
import { ReactNode } from 'react';
import LanguageSelector from '@/src/components/LanguageSelector';
import { H3 } from '@/src/components/TextHeaderWrapper';
import styles from './styles.module.scss';
export const Authentication = (props: { children: ReactNode }) => (
<>
<div className={styles.parameterHeading}>
<H3>Authentication</H3>
{/* No language selector because this is only used for curl */}
</div>
<div className={styles.parameters}>{props.children}</div>
</>
);
export const Parameters = (props: { children: ReactNode }) => (
<>
<div className={styles.parameterHeading}>
<H3>Parameters</H3>
<div className={styles.languageSelector}>
<LanguageSelector />
</div>
</div>
<div className={styles.parameters}>{props.children}</div>
</>
);
export const Response = (props: { children: ReactNode }) => (
<>
<div className={styles.parameterHeading}>
<H3>Response</H3>
<div className={styles.languageSelector}>
<LanguageSelector />
</div>
</div>
<div className={styles.parameters}>{props.children}</div>
</>
);

View File

@@ -0,0 +1,47 @@
@import '../../styles/utils/mixins';
.parameterHeading {
@include flex(row, left, center);
h3 {
@include generatePxToRem(margin-right, 6);
}
.languageSelector {
@include generatePxToRem(margin-top, 3);
}
}
.parameters {
@include generatePxToRem('margin', 20 0);
h5 {
font-family: var(--font-family-mono);
font-weight: normal;
@include generatePxToRem('font-size', 15);
@include generatePxToRem('margin', 0 0 8 0);
// Styling for parameter types
em {
font-style: normal !important;
}
> em {
color: var(--text-emphasis);
}
> a > em {
color: var(--text-emphasis);
}
}
p {
@include generatePxToRem('font-size', 14);
@include generatePxToRem('line-height', 20);
@include generatePxToRem('margin', 10 0);
}
> hr {
@include generatePxToRem('margin', 20 0, !important);
}
}

View File

@@ -0,0 +1,46 @@
import cn from 'classnames';
import { useContext } from 'react';
import { languageData } from '@/src/lib/languageData';
import { LanguageContext } from '@/src/utils/contexts/languageContext';
import styles from './styles.module.scss';
const SDKPicker = () => {
const {
language: currentLanguage,
setLanguage,
languages,
} = useContext(LanguageContext);
return (
<div className={styles.sdkPicker}>
{languages
.filter((language) => language !== 'curl')
.map((language) => {
const isActive = language === currentLanguage;
return (
<div
key={language}
onClick={() => setLanguage(language)}
className={cn(styles.item, {
[styles.active]: isActive,
})}
>
<div className={cn(styles.icon, styles[`${language}Icon`])}>
{languageData[language].Icon({
style: 'outline',
})}
</div>
<span className={styles.label}>
{languageData[language].title}
</span>
</div>
);
})}
</div>
);
};
export default SDKPicker;

View File

@@ -0,0 +1,170 @@
@import '@/src/styles/utils/mixins';
.sdkPicker {
display: grid;
grid-template-columns: repeat(4, 1fr);
@include generatePxToRem(column-gap, 16);
@include generatePxToRem(row-gap, 16);
@include tabletsMax1050 {
grid-template-columns: repeat(3, 1fr);
}
@include desktopMin1400 {
grid-template-columns: repeat(5, 1fr);
}
.item {
@include generatePxToRem(padding, 6 12);
border: 1px solid var(--hr-color);
border-radius: var(--base-border-radius);
cursor: pointer;
display: flex;
flex-direction: row;
align-items: center;
@include generatePxToRem(font-size, 14);
font-weight: 600;
letter-spacing: 0.01rem;
.icon {
width: 48px;
height: 48px;
@include generatePxToRem(margin-right, 8);
display: flex;
align-items: center;
justify-content: center;
}
.icon svg {
width: 100%;
height: 100%;
}
.goIcon svg {
width: 48px;
height: 48px;
margin-left: -6px;
}
.pythonIcon svg {
width: 34px;
height: 34px;
}
.csharpIcon svg {
width: 38px;
height: 38px;
margin-left: 2px;
}
.unityIcon svg {
width: 40px;
height: 40px;
margin-left: 4px;
}
.javaIcon svg {
width: 40px;
height: 40px;
margin-top: -4px;
}
.typescriptIcon svg {
width: 45px;
height: 45px;
margin-left: 0;
}
&:not(.active):hover {
background-color: rgba(0, 0, 0, 0.02);
}
&.active {
background-color: rgba(0, 0, 0, 0.06);
}
}
}
.sdkPicker {
.item {
&.active {
.label {
color: var(--primary-color);
}
.icon svg path {
fill: var(--primary-color);
}
}
&:not(.active) {
.label {
color: rgb(58, 57, 50);
opacity: 0.6;
}
.icon svg path {
fill: rgb(58, 57, 50);
opacity: 0.6;
}
&:hover {
.label {
color: var(--primary-color);
opacity: 0.7;
}
.icon svg path {
fill: var(--primary-color);
opacity: 0.7;
}
}
}
}
}
:global(.dark) .sdkPicker {
.item {
&.active {
.label {
color: var(--primary-color);
}
.icon svg path {
fill: var(--primary-color);
}
}
&:not(.active) {
.label {
color: #fff;
opacity: 0.5;
}
.icon svg path {
fill: #fff;
opacity: 0.5;
}
&:hover {
.label {
color: var(--primary-color);
opacity: 1;
}
.icon svg path {
fill: var(--primary-color);
opacity: 1;
}
}
}
&:not(.active):hover {
background-color: rgba(0, 0, 0, 0.1);
}
&.active {
background-color: rgba(0, 0, 0, 0.15);
}
}
}

View File

@@ -0,0 +1,44 @@
import React, { ReactElement, useContext } from 'react';
import styles from './styles.module.scss';
import { MultiPageContext } from '../scrollManager';
export const DocsSectionRouteContext = React.createContext('');
export const DocsSection = ({
route = '',
children,
}: {
route?: string;
children?: ReactElement[];
}) => {
const parentRoute = useContext(DocsSectionRouteContext);
const isMultiPage = useContext(MultiPageContext);
// if (parentRoute === '/') {
// parentRoute = '';
// }
//
// if (route.startsWith('/')) {
// route = route.slice(1);
// }
let homeOverride = '';
if (isMultiPage) {
// Root page content needs a route to live in, so wrap it in /home
if (parentRoute === '') {
homeOverride = '/';
}
}
let fullRoute = `${parentRoute}/${route}${homeOverride}`;
fullRoute = fullRoute.replaceAll('//', '/');
return (
<DocsSectionRouteContext.Provider value={fullRoute}>
<div className={styles.container}>{children}</div>
</DocsSectionRouteContext.Provider>
);
};

View File

@@ -0,0 +1,6 @@
@import '@/src/styles/utils/mixins';
.container {
@include generatePxToRem(max-width, 1400);
margin: auto;
}

View File

@@ -0,0 +1,34 @@
import { FC } from 'react';
import styles from './styles.module.scss';
const StatusCode: FC<{ code: string }> = ({ code }) => (
<span className={[styles.statusCodes_code, codeTypeStyle(code)].join(' ')}>
{code}
</span>
);
const codeTypeStyle = (code: string) => {
const range = parseInt(code[0]);
if (isNaN(range)) {
return styles.statusCodes_codeInformative;
}
switch (range) {
// 2xx
case 2:
return styles.statusCodes_codeSuccess;
// 3xx
case 3:
return styles.statusCodes_codeRedirect;
// 4xx, 5xx
case 4:
case 5:
return styles.statusCodes_codeError;
default:
return styles.statusCodes_codeInformative;
}
};
export default StatusCode;

View File

@@ -0,0 +1,35 @@
@import '../../styles/utils/mixins';
.statusCodes {
@include generatePxToRem('font-size', 14);
&_code {
font-family: var(--font-family-mono);
font-weight: normal;
&::before {
content: '';
display: inline-block;
width: 10px;
height: 10px;
border-radius: 5px;
@include generatePxToRem('margin-right', 6);
}
}
&_codeInformative::before {
background-color: var(--status-color-informative);
}
&_codeSuccess::before {
background-color: var(--status-color-success);
}
&_codeRedirect::before {
background-color: var(--status-color-redirect);
}
&_codeError::before {
background-color: var(--status-color-error);
}
}

View File

@@ -0,0 +1,54 @@
import { Children, FC, ReactNode, isValidElement, useState } from 'react';
import styles from './styles.module.scss';
export const TabbedSection: FC<{ tabLabel: string; children: ReactNode }> = ({
tabLabel,
children,
}) => {
const [selectedTab, setSelectedTab] = useState(0);
const tabs = Children.toArray(children)
.map((child) => (isValidElement<TabProps>(child) ? [child] : []))
.flat();
return (
<div className={styles.tabbedSection}>
<div className={styles.tabbedSection_heading}>
{tabLabel ? (
<span className={styles.tabbedSection_tabLabel}>{tabLabel}</span>
) : null}
{tabs.map((tab, index) => (
<button
className={[
styles.tabbedSection_tabButton,
index == selectedTab
? styles.tabbedSection_tabButton_selected
: null,
]
.filter((c) => c)
.join(' ')}
key={index}
onClick={() => setSelectedTab(index)}
>
{typeof tab.props.title === 'string' ? (
<span className={styles.tabbedSection_tabButton_title}>
tab.props.title
</span>
) : (
tab.props.title
)}
</button>
))}
<div className={styles.tabbedSection_headerFill} />
</div>
<div className={styles.tabbedSection_body}>{tabs[selectedTab]}</div>
</div>
);
};
export type TabProps = {
title: string | ReactNode;
children: ReactNode;
};
export const Tab: FC<TabProps> = ({ children }) => <div>{children}</div>;

View File

@@ -0,0 +1,78 @@
@import '../../styles/utils/mixins';
.tabbedSection {
border-radius: var(--base-border-radius);
border: 1px solid var(--hr-color);
backdrop-filter: blur(10px);
@include generatePxToRem('margin', 10 0);
padding: 0;
overflow: hidden;
// Make the breaks extend edge-to-edge
hr {
width: 120%;
@include generatePxToRem('margin', 12 -12 16 -12, !important);
}
&_heading {
@include flex(row, initial, stretch, nowrap);
transition: all 0.5s ease;
}
&_tabLabel {
@include generatePxToRem('font-size', 14);
@include generatePxToRem('padding-left', 16);
@include generatePxToRem('padding-right', 12);
@include generatePxToRem('padding-top', 8);
@include generatePxToRem('padding-bottom', 8);
border-bottom: 1px solid var(--hr-color);
}
&_tabButton {
@include generatePxToRem('font-size', 14);
border-left: 1px solid var(--hr-color);
border-bottom: 1px solid rgba(0, 0, 0, 0);
@include generatePxToRem('padding-left', 12);
@include generatePxToRem('padding-right', 12);
@include generatePxToRem('padding-top', 8);
@include generatePxToRem('padding-bottom', 8);
&_title {
font-family: var(--font-family-mono);
font-weight: normal;
}
&:not(.tabbedSection_tabButton_selected) {
border-bottom-color: var(--hr-color);
}
}
&_headerFill {
border: 0 solid var(--hr-color);
border-left-width: 1px;
border-bottom-width: 1px;
flex-grow: 1;
@include generatePxToRem('padding-left', 12);
}
&_body {
@include generatePxToRem('padding', 12);
p {
margin: 0;
}
p:not(:first-child) {
@include generatePxToRem('margin-top', 10);
}
p:not(:last-child) {
@include generatePxToRem('margin-bottom', 10);
}
:global(.ch-codeblock) {
margin: 0;
box-shadow: none;
}
}
}

View File

@@ -0,0 +1,130 @@
import React, {
createElement,
FC,
ReactNode,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ScrollContext } from '@/src/components/scrollManager';
import { toRouteFormat } from '@/src/utils/routesHelpers';
import styles from './styles.module.scss';
import { DocsSectionRouteContext } from '@/src/components/Section/section';
type textHeader = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
interface IHeaderProps {
headingType: textHeader;
children: ReactNode;
}
const TextHeaderWrapper: FC<IHeaderProps> = ({
headingType,
children = '',
}) => {
const route = useContext(DocsSectionRouteContext);
const scrollContext = useContext(ScrollContext);
const [isMouseOver, setIsMouseOver] = useState(false);
// Need both of these so we can fade before we switch the icon back
const [veryRecentlyCopied, setVeryRecentlyCopied] = useState(false);
const [recentlyCopied, setRecentlyCopied] = useState(false);
const headingValue = toRouteFormat(children?.toString());
const inputRef = useRef<HTMLHeadingElement>(null);
const pagePos = useMemo(
() =>
inputRef.current
? inputRef.current?.getBoundingClientRect().top + window.scrollY
: 0,
[inputRef.current?.getBoundingClientRect().top],
);
useEffect(() => {
if (inputRef.current && (headingType === 'h1' || headingType === 'h2')) {
scrollContext.upsertHeading(
route,
headingValue,
inputRef.current,
pagePos,
);
}
}, [pagePos]);
const heading = createElement(
headingType,
{
ref: inputRef,
id: route,
},
children,
);
const headingsToGiveAnchors = ['h1', 'h2', 'h3'];
const headingsToNestAnchors = ['h3'];
const link = useMemo(() => {
const origin =
typeof window !== 'undefined' && window.location.origin
? window.location.origin
: '';
return headingsToNestAnchors.includes(headingType)
? `${origin}${route}#${headingValue}`
: `${origin}${route}`;
}, [route]);
const handleAnchorClick = () => {
navigator.clipboard.writeText(link);
setRecentlyCopied(true);
};
useEffect(() => {
if (recentlyCopied) {
setVeryRecentlyCopied(true);
setTimeout(() => setVeryRecentlyCopied(false), 750);
setTimeout(() => setRecentlyCopied(false), 1000);
}
}, [recentlyCopied]);
const anchorButton = (
<button className={styles.anchor} onClick={handleAnchorClick}>
{recentlyCopied ? '✓' : '#'}
</button>
);
return headingsToGiveAnchors.includes(headingType) ? (
<div
style={{ position: 'relative' }}
onMouseEnter={() => setIsMouseOver(true)}
onMouseLeave={() => setIsMouseOver(false)}
>
{heading}
<div
style={{
position: 'absolute',
left: '-25px',
top: '50%',
transform: 'translateY(-50%)',
opacity: isMouseOver || veryRecentlyCopied ? 1 : 0,
transition: 'opacity 0.2s ease',
}}
>
{anchorButton}
</div>
</div>
) : (
heading
);
};
export const H3 = ({ children }: { children: ReactNode }) => (
<TextHeaderWrapper headingType='h3'>{children}</TextHeaderWrapper>
);
export default TextHeaderWrapper;

View File

@@ -0,0 +1,12 @@
@import '@/src/styles/utils/mixins';
.anchor {
@include generatePxToRem(width, 25);
@include generatePxToRem(font-size, 20);
font-family: var(--font-family-mono);
color: var(--text-body);
&:hover {
color: var(--text-link);
}
}

View File

@@ -0,0 +1,78 @@
import { useTheme } from 'next-themes';
import { FC, useEffect, useState } from 'react';
import Switch from 'react-switch';
import LanguageSelector from '@/src/components/LanguageSelector';
import Moon from '@/src/icons/Moon';
import Sun from '@/src/icons/Sun';
import styles from './styles.module.scss';
const DARK = 'dark';
const LIGHT = 'light';
const SYSTEM = 'system';
const ThemeToggle: FC = () => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme, resolvedTheme } = useTheme();
useEffect(() => {
if (theme === SYSTEM) {
setTheme(DARK);
}
}, []);
const isDark = theme === DARK || (theme === SYSTEM && resolvedTheme === DARK);
const onChangeTheme = () => {
setTheme(isDark ? LIGHT : DARK);
};
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<>
<LanguageSelector showIcon={true} />
<div className={styles.toggle}>
<label htmlFor='switch'>
<span>Light</span>
<Switch
id='switch'
className='react-switch'
onChange={onChangeTheme}
checked={isDark}
uncheckedIcon={false}
checkedIcon={false}
height={31}
width={51}
handleDiameter={26}
onColor='#2a2a2a'
offColor='#EFEFF1'
onHandleColor='#171717'
activeBoxShadow='0px 0px 1px 1px rgba(0, 0, 0, 0.2)'
offHandleColor='#FFF'
checkedHandleIcon={
<div className={styles.checkedIcon}>
<Moon />
</div>
}
uncheckedHandleIcon={
<div className={styles.checkedIcon}>
<Sun />
</div>
}
/>
<span>Dark</span>
</label>
</div>
</>
);
};
export default ThemeToggle;

View File

@@ -0,0 +1,20 @@
@import '@/src/styles/utils/mixins';
.toggle {
@include generatePxToRem(margin-left, 24);
label {
@include flex(row, center, center);
@include generatePxToRem(column-gap, 8);
cursor: pointer;
span {
@include tabletsMax768 {
display: none;
}
}
}
}
.checkedIcon {
@include flex(row, center, center);
height: 100%;
}

View File

@@ -0,0 +1,11 @@
import styles from './styles.module.scss';
const Watermark = () => (
<a
className={styles.watermark}
href='https://speakeasyapi.dev'
target='_blank'
></a>
);
export default Watermark;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,24 @@
import React, { FC } from 'react';
import styles from './styles.module.scss';
const WithStatusBar: FC<{
statusItems: [string, string][];
children: React.ReactNode;
}> = ({ statusItems, children }) => (
<div className={styles.withStatusBar}>
{children}
{statusItems ? (
<div className={styles.withStatusBar_infoBar}>
{statusItems.map(([title, value], index) => (
<div key={index}>
<strong>{title}</strong>{' '}
<span className={styles.value}>{value}</span>
</div>
))}
</div>
) : null}
</div>
);
export default WithStatusBar;

View File

@@ -0,0 +1,38 @@
@import '../../styles/utils/mixins';
.withStatusBar {
background-color: #212121;
border-radius: 4px;
:global(.ch-codeblock) {
border-radius: 0 !important;
}
:global(.ch-code) {
background: none;
}
&_infoBar {
color: #8D8D90;
font-family: var(--font-family);
font-weight: normal;
border-top: 1px solid #44444A;
@include generatePxToRem('font-size', 13);
@include generatePxToRem('padding-left', 16);
@include generatePxToRem('padding-bottom', 8);
@include generatePxToRem('padding-top', 7);
display: flex;
flex-direction: row;
> *:not(:last-child) {
@include generatePxToRem('margin-right', 16);
}
}
.value {
font-family: var(--font-family-mono);
font-weight: normal;
@include generatePxToRem('font-size', 13);
@include generatePxToRem('margin-left', 4);
}
}

View File

@@ -0,0 +1,49 @@
import { useLayoutEffect } from 'react';
import { useRouter } from 'next/router';
import { Languages, DefaultLanguage } from '@/content/languages';
type Redirect = {
from: string;
to: string;
};
export const CustomRedirects = () => {
const router = useRouter();
// Static redirects + custom redirects defined in theme.yaml
const redirects: Redirect[] = [
{
from: '/',
to: `/${DefaultLanguage}/client_sdks/`,
},
...Languages.map((lang) => ({
from: `/${lang}`,
to: `/${lang}/client_sdks/`,
})),
];
useLayoutEffect(() => {
const currentPath = window.location.pathname;
const matchedRedirect = redirects.find((r) => {
if (!r.from.endsWith('*')) {
return r.from === currentPath;
}
const basePath = r.from.replace('*', '');
return currentPath.startsWith(basePath);
});
if (matchedRedirect) {
const newPath = matchedRedirect.from.endsWith('*')
? matchedRedirect.to.replace('*', '') +
currentPath.replace(matchedRedirect.from.replace('*', ''), '')
: matchedRedirect.to;
router.replace(newPath);
}
}, []);
return null;
};

24
src/components/head.tsx Normal file
View File

@@ -0,0 +1,24 @@
import React from 'react';
import { useConfig } from '@speakeasy-sdks/nextra-theme-docs';
import { theme } from '@/src/utils/theme';
export const Head = () => {
const { frontMatter } = useConfig();
const title = 'Reference'; // TODO frontMatter.title || 'Reference';
return (
<>
<title>{title}</title>
<meta property='og:title' content={title} />
<meta
property='og:description'
content={frontMatter.description || 'SDK reference'}
/>
<link rel='preconnect' href='https://fonts.gstatic.com' />
<link href={theme.fonts.main.url} rel='stylesheet' />
<link href={theme.fonts.code.url} rel='stylesheet' />
<link href={theme.favicon} rel={'icon'} />
</>
);
};

View File

@@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export const RootContainer = (props: { children: ReactNode }) => (
<div>{props.children}</div>
);

View File

@@ -0,0 +1,61 @@
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from 'react';
import { useRouter } from 'next/router';
type RouteProviderProps = {
route: string;
setRoute: (route: string) => void;
};
export const RouteContext = createContext<RouteProviderProps>({
route: '/',
setRoute: () => {},
});
export const useRoute = () => useContext(RouteContext).route;
export const RouteProvider = (props: { children: ReactNode }) => {
const router = useRouter();
const [route, setRoute] = useState('');
useEffect(() => {
setRoute(window.location.pathname);
}, []);
const handleRouteChange = (newRoute: string) => {
const currentBasePage = route.split('/').at(1);
// If we're on the same root page, just update the URL so that we don't have to reload the page
if (newRoute.startsWith('/' + currentBasePage)) {
updateUrlShallow(window, newRoute);
} else {
// This causes the page to reload
router.push(newRoute, undefined, { scroll: false });
}
setRoute(newRoute);
};
return (
<RouteContext.Provider value={{ route, setRoute: handleRouteChange }}>
{props.children}
</RouteContext.Provider>
);
};
// We take in a window to ensure this is only called from where window is available
export const updateUrlShallow = (window: Window, url: string) => {
window.history.replaceState(
{
...window.history.state,
as: url,
url: url,
},
'',
url,
);
};

View File

@@ -0,0 +1,223 @@
import React, {
createContext,
ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { RouteContext } from '@/src/components/routeProvider';
export const MultiPageContext = createContext(false);
export const ScrollContext = createContext<{
headingToPosition: Record<string, HeadingPosition>;
currentHeading: string;
visibleHeadings: string[];
upsertHeading: (
route: string,
heading: string,
elem: HTMLHeadingElement,
position: number,
) => void;
scrollTo: (route: string) => void;
setPage: (route: string) => void;
}>({
headingToPosition: {},
currentHeading: '',
visibleHeadings: [],
upsertHeading: () => {},
scrollTo: () => {},
setPage: () => {},
});
export const useSetPage = () => useContext(ScrollContext).setPage;
type HeadingPosition = {
elem: HTMLHeadingElement;
position: number;
};
// Used to change the route a bit before the heading is at the top of the page
const headingOffset = -200;
export const ScrollManager = (props: {
children: ReactNode;
}): React.ReactElement => {
const isMultipage = useContext(MultiPageContext);
// const slug = pathname !== null ? pathname : undefined;
const { route, setRoute } = useContext(RouteContext);
const [initialScrollTarget, setInitialScrollTarget] = useState<string>();
const [initialScrollDone, setInitialScrollDone] = useState(false);
const rootPage = useMemo(
() => (isMultipage ? route?.split('/').at(1) ?? '' : ''),
[route],
);
const reset = () => {
setHeadingToPosition({});
setInitialScrollDone(false);
setInitialScrollTarget(undefined);
};
const setPage = async (route: string) => {
reset();
setInitialScrollTarget(route);
setRoute(route);
// await router.push(route, route, { scroll: false });
};
const [headingToPosition, setHeadingToPosition] = useState<
Record<string, HeadingPosition>
>({});
const upsertHeading = (
route: string,
heading: string,
elem: HTMLHeadingElement,
position: number,
) => {
setHeadingToPosition((currentValues) => {
position = position + headingOffset;
// If there are multiple headings in a section, we want to keep only the topmost one.
// As a result, clicking the link in the sidebar will correctly scroll to the top of the section
const current = currentValues[route];
if (current && current.elem !== elem && position > current.position) {
route += `#${heading}`;
}
return {
...currentValues,
[route]: {
elem,
position,
},
};
});
};
const [closestHeading, setClosestHeading] = useState<string>('/' + rootPage);
const [visibleHeadings, setVisibleHeadings] = useState<string[]>([
closestHeading,
]);
/**
* This is responsible for setting the route in the URL to the closest heading when the user scrolls.
* This is memoized so that it can be removed when the route changes (otherwise it prevents scrolling to the desired heading)
*/
const scroll = useMemo(
() => () => {
const entries = Object.entries(headingToPosition);
const visible = entries
.filter(
([_, { position }]) =>
window.scrollY < position &&
position < window.scrollY + window.innerHeight,
)
.map(([route]) => route);
// Find the first heading that is below the current scroll position
const nextIndex = entries.findIndex(
([_, { position }]) => position > window.scrollY,
);
// The current heading is the one before that
const currentIndex =
nextIndex === -1
? entries.length - 1
: nextIndex - 1 >= 0
? nextIndex - 1
: 0;
const closest = entries[currentIndex]?.[0];
setClosestHeading(closest);
setVisibleHeadings([closest, ...visible]);
},
[headingToPosition],
);
useEffect(() => {
window.addEventListener('scroll', scroll, false);
return () => window.removeEventListener('scroll', scroll, false);
}, [scroll]);
useEffect(() => {
if (
closestHeading &&
initialScrollDone &&
closestHeading.startsWith(`/${rootPage}`) // Make sure we haven't changed pages. Without this, we might overwrite the new route
) {
setRoute(closestHeading);
}
}, [closestHeading]);
// Scrolls the page to the location of the target heading
const scrollTo = useMemo(
() => (route: string) => {
if (headingToPosition[route]) {
document.addEventListener(
'scrollend',
() => {
setClosestHeading(route);
},
{ once: true },
);
// Scroll down a bit further than the heading so that it lines up right at the top
window.scrollTo({ top: headingToPosition[route].position + 100 });
}
},
[headingToPosition],
);
/**
* On initial page load, set the heading to scroll to
* This enables linking to a specific section
* We don't want to run this every time the slug changes since we change it as the user scrolls
*/
useEffect(() => {
// At first, the slug is simply /[...rest], so wait til it properly pulls in the URL
if (route && !initialScrollTarget) {
setInitialScrollTarget(route);
}
}, [route]);
// Once the initial scroll target is set and we know where that heading is, scroll to it
// Do this every time the heading location changes until it stabilizes. This is necessary because the heading
// will change positions on the page a few different times as the page loads. We want to scroll to it every time
// it changes to reduce the perception of lagginess.
useEffect(() => {
let t: NodeJS.Timeout;
if (
initialScrollTarget &&
headingToPosition[initialScrollTarget] &&
!initialScrollDone
) {
scrollTo(initialScrollTarget);
t = setTimeout(() => {
setInitialScrollDone(true);
}, 50);
}
return () => clearTimeout(t);
}, [initialScrollTarget && headingToPosition[initialScrollTarget]]);
return (
<ScrollContext.Provider
value={{
headingToPosition,
upsertHeading,
currentHeading: closestHeading,
visibleHeadings,
scrollTo,
setPage,
}}
>
{props.children}
</ScrollContext.Provider>
);
};

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { partition } from 'lodash';
// Needs to support both arrays of children and MDXContent nodes (whose children first need to be extracted)
export const splitByType = (
node: React.ReactNode,
type: (props: { children: React.ReactNode }) => React.JSX.Element,
): [React.ReactNode[], React.ReactNode[]] => {
if (Array.isArray(node)) {
return splitElementsByType(node, type);
} else {
return splitMDXContentChildrenByType(node, type);
}
};
// Assumes the parent is an MDXContent node
export const splitMDXContentChildrenByType = (
parent: React.ReactNode,
type: (props: { children: React.ReactNode }) => React.JSX.Element,
): [React.ReactNode[], React.ReactNode[]] => {
let children = (parent as any).props?.children;
if (!children) {
if (
'type' in (parent as any) &&
typeof (parent as any).type === 'function'
) {
children = (parent as any).type().props.children;
} else {
return [[], []];
}
}
return splitElementsByType(children, type);
};
export const splitElementsByType = (
elements: React.ReactElement[],
type: (props: { children: React.ReactNode }) => React.JSX.Element,
): [React.ReactElement[], React.ReactElement[]] =>
partition(elements, (e: any) => e?.type === type);
export const typeMatches = (
e: React.ReactNode,
type: () => React.JSX.Element,
): boolean => 'type' in (e as any) && (e as any).type === type;
export const splitAround = <T,>(a: T[], fn: (e: T) => boolean): [T[], T[]] => {
const breakIndex = a.findIndex(fn);
if (breakIndex === -1) {
return [a, []];
}
return [a.slice(0, breakIndex), a.slice(breakIndex + 1)];
};
export const separateHeadingsAndOthers = (elements: any) => {
const headings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
const headingsArray = [];
const othersArray = [];
let isHeading = true;
for (let i = 0; i < elements.length; i++) {
// Check if the element is a React element
if (elements[i]?.['$$typeof'] === Symbol.for('react.element')) {
const elementType = elements[i].type?.name?.toLowerCase();
// Check if the element type is one of the headings
if (headings.includes(elementType)) {
// If isHeading is true, add to headingsArray, else to othersArray
if (isHeading) {
headingsArray.push(elements[i]);
} else {
othersArray.push(elements[i]);
}
} else {
// Non-heading element, so subsequent heading tags should go to othersArray
othersArray.push(elements[i]);
isHeading = false;
}
} else {
// Non-React element (like '\n'), just add to othersArray
othersArray.push(elements[i]);
}
}
return [headingsArray, othersArray];
};