Skip to content

Commit ca3e006

Browse files
committed
fetch Readme async on the client, fix relative images, add loading state
1 parent 83542e8 commit ca3e006

7 files changed

Lines changed: 188 additions & 49 deletions

File tree

common/styleguide.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import * as HtmlElements from '@expo/html-elements';
22
import { type TextProps } from '@expo/html-elements/build/primitives/Text';
33
import Link from 'next/link';
4-
import { type ComponentType, type PropsWithChildren, useContext, useState } from 'react';
4+
import {
5+
type ComponentType,
6+
type CSSProperties,
7+
type PropsWithChildren,
8+
useContext,
9+
useState,
10+
} from 'react';
511
import {
612
StyleSheet,
713
type TextStyle,
814
View,
915
useWindowDimensions,
10-
type ViewStyle,
1116
type StyleProp,
1217
} from 'react-native';
1318

@@ -132,7 +137,7 @@ type AProps = PropsWithChildren<{
132137
target?: string;
133138
href: string;
134139
hoverStyle?: StyleProp<TextStyle>;
135-
containerStyle?: StyleProp<ViewStyle>;
140+
containerStyle?: CSSProperties | undefined;
136141
}>;
137142

138143
export function A({ href, target, children, style, hoverStyle, containerStyle, ...rest }: AProps) {
@@ -161,10 +166,10 @@ export function A({ href, target, children, style, hoverStyle, containerStyle, .
161166
}
162167

163168
return (
164-
<View
169+
<span
165170
onPointerEnter={() => setIsHovered(true)}
166171
onPointerLeave={() => setIsHovered(false)}
167-
style={containerStyle}>
172+
style={{ display: 'contents', ...containerStyle }}>
168173
<HtmlElements.A
169174
{...rest}
170175
href={href}
@@ -174,7 +179,7 @@ export function A({ href, target, children, style, hoverStyle, containerStyle, .
174179
style={[linkStyles, isHovered && linkHoverStyles, style, isHovered && hoverStyle]}>
175180
{children}
176181
</HtmlElements.A>
177-
</View>
182+
</span>
178183
);
179184
}
180185

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@ import { useContext } from 'react';
22
import { StyleSheet, View } from 'react-native';
33

44
import { A, Caption, colors, Label } from '~/common/styleguide';
5+
import CustomAppearanceContext from '~/context/CustomAppearanceContext';
56
import { type NpmUser } from '~/types';
67

7-
import CustomAppearanceContext from '../context/CustomAppearanceContext';
8-
98
type Props = {
109
author?: NpmUser;
1110
size?: 'sm' | 'md';
1211
};
1312

14-
export function PackageAuthor({ author }: Props) {
13+
export default function PackageAuthor({ author }: Props) {
1514
const { isDark } = useContext(CustomAppearanceContext);
1615

1716
if (!author) {

components/Details/ReadmeBox.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { Md } from '@m2d/react-markdown/client';
2+
import { useEffect, useState } from 'react';
3+
import { StyleSheet, View } from 'react-native';
4+
import rehypeRaw from 'rehype-raw';
5+
import rehypeSanitize from 'rehype-sanitize';
6+
import remarkGfm from 'remark-gfm';
7+
8+
import { A, colors, darkColors, P } from '~/common/styleguide';
9+
import { ReadmeFile } from '~/components/Icons';
10+
import { getReadmeAssetURL } from '~/util/getReadmeAssetUrl';
11+
12+
type Props = {
13+
packageName?: string;
14+
githubUrl?: string;
15+
isDark?: boolean;
16+
loader?: boolean;
17+
};
18+
19+
export default function ReadmeBox({ packageName, githubUrl, isDark, loader }: Props) {
20+
const [readmeContent, setReadmeContent] = useState<string | null>(null);
21+
22+
useEffect(() => {
23+
if (loader) {
24+
return;
25+
}
26+
27+
let cancelled = false;
28+
void (async () => {
29+
try {
30+
const readmeResponse = await fetch(`https://unpkg.com/${packageName}/README.md`);
31+
const readmeContent = await readmeResponse.text();
32+
if (!cancelled) {
33+
setReadmeContent(readmeContent);
34+
}
35+
} catch {
36+
if (!cancelled) {
37+
setReadmeContent(null);
38+
}
39+
}
40+
})();
41+
return () => {
42+
cancelled = true;
43+
};
44+
}, []);
45+
46+
return (
47+
<View
48+
id="readmeMarkdownWrapper"
49+
style={[
50+
styles.readmeWrapper,
51+
{
52+
// @ts-expect-error allow color style inheritance
53+
color: isDark ? colors.white : colors.black,
54+
borderColor: isDark ? darkColors.border : colors.gray2,
55+
},
56+
]}>
57+
<View
58+
style={[
59+
styles.readmeHeader,
60+
{
61+
borderColor: isDark ? darkColors.border : colors.gray2,
62+
},
63+
]}>
64+
<ReadmeFile fill={isDark ? darkColors.pewter : colors.secondary} />
65+
<P>Readme.md</P>
66+
</View>
67+
<View style={styles.readmeContainer}>
68+
{readmeContent && githubUrl ? (
69+
// TODO: collapse Readme content by default, expand on user interaction
70+
<Md
71+
components={{
72+
hr: () => null,
73+
div: () => null,
74+
a: (props: any) => <A style={{ display: 'contents' }} {...props} />,
75+
img: ({ src, alt }: any) => (
76+
<img src={getReadmeAssetURL(src, githubUrl)} alt={alt ?? ''} />
77+
),
78+
// TODO: skip broken/non-loading images
79+
// TODO: render blockquotes in a better way, support GH themed notes
80+
// TODO: render code block in a better way
81+
// TODO: render tables in a better way
82+
}}
83+
rehypePlugins={[rehypeRaw, rehypeSanitize]}
84+
remarkPlugins={[remarkGfm]}>
85+
{readmeContent}
86+
</Md>
87+
) : (
88+
<P style={styles.loadingContent}>Loading README.md…</P>
89+
)}
90+
</View>
91+
</View>
92+
);
93+
}
94+
95+
const styles = StyleSheet.create({
96+
readmeWrapper: {
97+
borderRadius: 12,
98+
borderWidth: 1,
99+
borderStyle: 'solid',
100+
marginBottom: 8,
101+
},
102+
readmeHeader: {
103+
flexDirection: 'row',
104+
gap: 8,
105+
alignItems: 'center',
106+
paddingVertical: 12,
107+
paddingHorizontal: 16,
108+
borderBottomWidth: 1,
109+
borderStyle: 'solid',
110+
},
111+
readmeContainer: {
112+
padding: 16,
113+
paddingTop: 12,
114+
},
115+
loadingContent: {
116+
textAlign: 'center',
117+
paddingVertical: 24,
118+
},
119+
});

components/Icons/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,3 +528,11 @@ export function Tools({ width = 24, height = 24, fill = colors.black }: IconProp
528528
</Svg>
529529
);
530530
}
531+
532+
export function ReadmeFile({ width = 24, height = 24, fill = colors.black }: IconProps) {
533+
return (
534+
<Svg width={width} height={height} fill={fill} viewBox="0 0 256 256">
535+
<Path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40v72a8,8,0,0,0,16,0V40h88V88a8,8,0,0,0,8,8h48V224a8,8,0,0,0,16,0V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM144,144H128a8,8,0,0,0-8,8v56a8,8,0,0,0,8,8h16a36,36,0,0,0,0-72Zm0,56h-8V160h8a20,20,0,0,1,0,40Zm-40-48v56a8,8,0,0,1-16,0V177.38L74.55,196.59a8,8,0,0,1-13.1,0L48,177.38V208a8,8,0,0,1-16,0V152a8,8,0,0,1,14.55-4.59L68,178.05l21.45-30.64A8,8,0,0,1,104,152Z" />
536+
</Svg>
537+
);
538+
}

pages/package/[...name].tsx

Lines changed: 18 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,24 @@
1-
import { Md } from '@m2d/react-markdown/client';
21
import { type NextPageContext } from 'next';
2+
import dynamic from 'next/dynamic';
33
import * as emoji from 'node-emoji';
44
import { useContext } from 'react';
55
import { Linkify } from 'react-easy-linkify';
66
import { Platform, StyleSheet, View } from 'react-native';
7-
import rehypeRaw from 'rehype-raw';
8-
import rehypeSanitize from 'rehype-sanitize';
9-
import remarkGfm from 'remark-gfm';
107

118
import { A, colors, darkColors, H6, Headline, Label, P, useLayout } from '~/common/styleguide';
129
import { Button } from '~/components/Button';
1310
import { CompatibilityTags } from '~/components/CompatibilityTags';
1411
import ContentContainer from '~/components/ContentContainer';
15-
import DependencyRow from '~/components/DependencyRow';
12+
import DependencyRow from '~/components/Details/DependencyRow';
13+
import PackageAuthor from '~/components/Details/PackageAuthor';
14+
import ReadmeBox from '~/components/Details/ReadmeBox';
1615
import { MetaData } from '~/components/Library/MetaData';
1716
import Thumbnail from '~/components/Library/Thumbnail.web';
1817
import TrendingMark from '~/components/Library/TrendingMark';
1918
import UnmaintainedLabel from '~/components/Library/UnmaintainedLabel';
2019
import UpdatedAtView from '~/components/Library/UpdateAtView';
2120
import Navigation from '~/components/Navigation';
2221
import NotFoundContent from '~/components/NotFoundContent';
23-
import { PackageAuthor } from '~/components/PackageAuthor';
2422
import PageMeta from '~/components/PageMeta';
2523
import CustomAppearanceContext from '~/context/CustomAppearanceContext';
2624
import { type LibraryType, type NpmLatestRegistryData, type NpmUser } from '~/types';
@@ -35,12 +33,11 @@ type Props = {
3533
libraries: LibraryType[];
3634
};
3735
registryData?: NpmLatestRegistryData;
38-
readmeContent?: string;
3936
};
4037

4138
// TODO: async render/data fetch
4239
// TODO: responsive/mobile viewports
43-
export default function PackagePage({ apiData, registryData, packageName, readmeContent }: Props) {
40+
export default function PackagePage({ apiData, registryData, packageName }: Props) {
4441
const { isDark } = useContext(CustomAppearanceContext);
4542
const { isSmallScreen } = useLayout();
4643

@@ -104,32 +101,11 @@ export default function PackagePage({ apiData, registryData, packageName, readme
104101
</Linkify>
105102
</Headline>
106103
)}
107-
{readmeContent && (
108-
<View
109-
id="readmeMarkdownWrapper"
110-
style={[
111-
styles.readmeWrapper,
112-
{
113-
// @ts-expect-error allow color style inheritance
114-
color: isDark ? colors.white : colors.black,
115-
borderColor: isDark ? darkColors.border : colors.gray2,
116-
},
117-
]}>
118-
<Md
119-
components={{
120-
hr: () => null,
121-
a: (props: any) => <A containerStyle={{ display: 'inline-flex' }} {...props} />,
122-
// TODO: decide if we want to remove images/assets, or fix relative assets links
123-
// TODO: render blockquotes in a better way, support GH themed notes
124-
// TODO: render code block in a better way
125-
// TODO: render tables in a better way
126-
}}
127-
rehypePlugins={[rehypeRaw, rehypeSanitize]}
128-
remarkPlugins={[remarkGfm]}>
129-
{readmeContent}
130-
</Md>
131-
</View>
132-
)}
104+
<ReadmeBoxWithLoading
105+
packageName={packageName}
106+
isDark={isDark}
107+
githubUrl={library.githubUrl}
108+
/>
133109
{library.examples && library.examples.length > 0 && (
134110
<>
135111
<H6 style={[styles.mainContentHeader, headerColorStyle]}>Code examples</H6>
@@ -255,6 +231,10 @@ export default function PackagePage({ apiData, registryData, packageName, readme
255231
);
256232
}
257233

234+
const ReadmeBoxWithLoading = dynamic(() => import('~/components/Details/ReadmeBox'), {
235+
loading: () => <ReadmeBox loader />,
236+
});
237+
258238
const styles = StyleSheet.create({
259239
container: {
260240
paddingVertical: 32,
@@ -364,8 +344,10 @@ export async function getServerSideProps(ctx: NextPageContext) {
364344

365345
if (!packageName) {
366346
return {
367-
data: {},
368-
packageName,
347+
props: {
348+
data: {},
349+
packageName,
350+
},
369351
};
370352
}
371353

@@ -377,15 +359,11 @@ export async function getServerSideProps(ctx: NextPageContext) {
377359
const npmResponse = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
378360
const registryData = await npmResponse.json();
379361

380-
const readmeResponse = await fetch(`https://unpkg.com/${packageName}/README.md`);
381-
const readmeContent = await readmeResponse.text();
382-
383362
return {
384363
props: {
385364
packageName,
386365
apiData,
387366
registryData,
388-
readmeContent,
389367
},
390368
};
391369
}

util/getReadmeAssetUrl.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export function getReadmeAssetURL(src: string, githubUrl: string) {
2+
if (!src.startsWith(`http`)) {
3+
const rawGitHubUrl = githubUrl
4+
.replace('https://github.com', 'https://raw.githubusercontent.com')
5+
.replace('/tree', '');
6+
return joinPosix(rawGitHubUrl, src).replace('https:/', 'https://');
7+
}
8+
return src;
9+
}
10+
11+
function joinPosix(...parts: string[]): string {
12+
return normalizePosix(parts.filter(Boolean).join('/'));
13+
}
14+
15+
function normalizePosix(path: string): string {
16+
const absolute = path.startsWith('/');
17+
const segments = path.split('/').filter(Boolean);
18+
const out: string[] = [];
19+
for (const segment of segments) {
20+
if (segment === '.') {
21+
continue;
22+
}
23+
if (segment === '..') {
24+
out.pop();
25+
} else {
26+
out.push(segment);
27+
}
28+
}
29+
return (absolute ? '/' : '') + out.join('/');
30+
}

0 commit comments

Comments
 (0)