diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index c971ca00..d68dbb62 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -1366,3 +1366,253 @@ export function Spinner({ width = 24, height = 24, style }: IconProps) { ); } + +export function FileIcon({ width = 24, height = 24, style }: IconProps) { + return ( + + + + + + + ); +} + +export function DownloadFileIcon({ width = 24, height = 24, style }: IconProps) { + return ( + + + + + + + ); +} + +export function TempFileIcon({ width = 24, height = 24, style }: IconProps) { + return ( + + + + + + + + + + ); +} + +export function FolderIcon({ width = 24, height = 24, style }: IconProps) { + return ( + + + + + ); +} + +export function ImageFileIcon({ width = 24, height = 24, style }: IconProps) { + return ( + + + + + + ); +} + +export function CodeIcon({ width = 24, height = 24, style }: IconProps) { + return ( + + + + + + ); +} diff --git a/components/NavigationTab.tsx b/components/NavigationTab.tsx index b45d78b8..4a6c23b3 100644 --- a/components/NavigationTab.tsx +++ b/components/NavigationTab.tsx @@ -8,7 +8,7 @@ import tw from '~/util/tailwind'; type Props = { title: string; path?: string; - counter?: number; + counter?: number | string; }; function NavigationTab({ title, counter, path = `/${title.toLowerCase()}` }: Props) { diff --git a/components/Package/CodeBrowser/CodeBrowserContent.tsx b/components/Package/CodeBrowser/CodeBrowserContent.tsx new file mode 100644 index 00000000..532da6da --- /dev/null +++ b/components/Package/CodeBrowser/CodeBrowserContent.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { Pressable, View } from 'react-native'; +import { type Theme } from 'react-shiki'; +import useSWR from 'swr'; + +import { Label, P } from '~/common/styleguide'; +import { CodeIcon, ImageFileIcon, TempFileIcon } from '~/components/Icons'; +import CodeBrowserContentHeader from '~/components/Package/CodeBrowser/CodeBrowserContentHeader'; +import DownloadFileButton from '~/components/Package/CodeBrowser/DownloadFileButton'; +import CopyButton from '~/components/Package/CopyButton'; +import ThreeDotsLoader from '~/components/Package/ThreeDotsLoader'; +import Tooltip from '~/components/Tooltip'; +import rndDark from '~/styles/shiki/rnd-dark.json'; +import rndLight from '~/styles/shiki/rnd-light.json'; +import { type UnpkgMeta } from '~/types'; +import { IMAGE_FILES, PREVIEW_DISABLED_FILES } from '~/util/codeBrowser'; +import { TimeRange } from '~/util/datetime'; +import { formatBytes } from '~/util/formatBytes'; +import tw from '~/util/tailwind'; + +import CodeBrowserContentHighlighter from './CodeBrowserContentHighlighter'; + +type Props = { + packageName: string; + filePath: string; + fileData?: UnpkgMeta['files'][number]; +}; + +export default function CodeBrowserContent({ packageName, filePath, fileData }: Props) { + const fileExtension = filePath.split('.').at(-1) ?? 'text'; + const [rawPreview, setRawPreview] = useState(false); + + const isTooBig = Boolean(fileData?.size && fileData.size > 1024 * 1024 * 4); + const isPreviewDisabled = PREVIEW_DISABLED_FILES.includes(fileExtension) || isTooBig; + const isImageFile = IMAGE_FILES.includes(fileExtension); + const allowRawPreview = isImageFile && filePath.endsWith('.svg'); + + const { data, isLoading } = useSWR( + !isPreviewDisabled && (!isImageFile || (isImageFile && rawPreview)) + ? `/api/proxy/unpkg?name=${packageName}&path=${filePath.replaceAll('+', '%2B')}` + : undefined, + (url: string) => + fetch(url).then(res => { + if (res.status === 200) { + return res.text(); + } + return res.json(); + }), + { + dedupingInterval: TimeRange.HOUR * 1000, + revalidateOnFocus: false, + } + ); + + if (isLoading) { + return ( + <> + + + + + + ); + } + + if (!isLoading && data && typeof data === 'string') { + return ( + <> + + + {allowRawPreview && ( + setRawPreview(false)}> + + + }> + Show image preview + + )} + + + + + + + ); + } + + return ( + <> + + + {allowRawPreview && !rawPreview && ( + setRawPreview(true)}> + + + }> + Show code + + )} + {(isPreviewDisabled || isImageFile) && ( + + )} + + + + {isImageFile && ( + + )} + {!isImageFile && isPreviewDisabled && ( + + +

+ {fileData?.size && isTooBig + ? `This file is too big (${formatBytes(fileData?.size)}), and cannot be previewed.` + : 'This file cannot be previewed.'} +

+ +
+ )} + {!isPreviewDisabled && !isImageFile && ( +

+ Cannot fetch "{filePath}" file content. +

+ )} +
+ + ); +} diff --git a/components/Package/CodeBrowser/CodeBrowserContentHeader.tsx b/components/Package/CodeBrowser/CodeBrowserContentHeader.tsx new file mode 100644 index 00000000..af09cb09 --- /dev/null +++ b/components/Package/CodeBrowser/CodeBrowserContentHeader.tsx @@ -0,0 +1,22 @@ +import { type PropsWithChildren } from 'react'; +import { View } from 'react-native'; + +import { P } from '~/common/styleguide'; +import tw from '~/util/tailwind'; + +type Props = PropsWithChildren<{ + filePath: string; +}>; + +export default function CodeBrowserContentHeader({ filePath, children }: Props) { + return ( + +

{filePath}

+ {children} +
+ ); +} diff --git a/components/Package/CodeBrowser/CodeBrowserContentHighlighter.tsx b/components/Package/CodeBrowser/CodeBrowserContentHighlighter.tsx new file mode 100644 index 00000000..8cee5c48 --- /dev/null +++ b/components/Package/CodeBrowser/CodeBrowserContentHighlighter.tsx @@ -0,0 +1,27 @@ +import { type Theme, useShikiHighlighter } from 'react-shiki'; + +import { SHIKI_OPTS } from '~/util/shiki'; +import tw from '~/util/tailwind'; + +type Props = { + code: string; + lang: string; + theme: Theme; +}; + +export default function CodeBrowserContentHighlighter({ code, lang, theme }: Props) { + const highlighter = useShikiHighlighter(code, lang, theme, { + showLineNumbers: true, + ...SHIKI_OPTS, + }); + + if (!highlighter) { + return ( +
+        {code}
+      
+ ); + } + + return highlighter; +} diff --git a/components/Package/CodeBrowser/CodeBrowserFileRow.tsx b/components/Package/CodeBrowser/CodeBrowserFileRow.tsx new file mode 100644 index 00000000..41e9a302 --- /dev/null +++ b/components/Package/CodeBrowser/CodeBrowserFileRow.tsx @@ -0,0 +1,79 @@ +import { useMemo, useState } from 'react'; +import { Pressable, View } from 'react-native'; + +import { Label } from '~/common/styleguide'; +import { FileIcon, FolderIcon, WarningBlockquote } from '~/components/Icons'; +import Tooltip from '~/components/Tooltip'; +import { getFileWarning } from '~/util/codeBrowser'; +import tw from '~/util/tailwind'; + +type Props = { + label: string; + depth?: number; + isActive?: boolean; + isDirectory?: boolean; + onPress?: () => void; +}; + +export default function CodeBrowserFileRow({ + label, + depth = 0, + isActive = false, + isDirectory = false, + onPress, +}: Props) { + const [isHovered, setIsHovered] = useState(false); + const warning = isDirectory ? undefined : getFileWarning(label); + + const Icon = useMemo(() => (isDirectory ? FolderIcon : FileIcon), [isDirectory]); + + const rowStyle = [ + tw`flex flex-row items-center gap-1.5 px-3 py-1 last:mb-20`, + { paddingLeft: 12 + depth * 8 }, + ]; + + const content = ( + <> + + + {warning && ( + + + + }> + {warning.message} + + )} + + ); + + if (!onPress) { + return {content}; + } + + return ( + setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)}> + {content} + + ); +} diff --git a/components/Package/CodeBrowser/CodeBrowserFileTree.tsx b/components/Package/CodeBrowser/CodeBrowserFileTree.tsx new file mode 100644 index 00000000..ffe996fe --- /dev/null +++ b/components/Package/CodeBrowser/CodeBrowserFileTree.tsx @@ -0,0 +1,66 @@ +import { View } from 'react-native'; + +import { type CodeBrowserTreeDirectory } from '~/types'; + +import CodeBrowserFileRow from './CodeBrowserFileRow'; + +type Props = { + tree: CodeBrowserTreeDirectory; + activeFile: string | null; + onSelectFile: (filePath: string) => void; + depth?: number; +}; + +export default function CodeBrowserFileTree({ tree, activeFile, onSelectFile, depth = 0 }: Props) { + const directories = Object.values(tree.directories).sort((a, b) => a.name.localeCompare(b.name)); + const files = [...tree.files].sort((a, b) => a.name.localeCompare(b.name)); + + return ( + <> + {directories.map(directory => { + const collapsedDirectory = collapseDirectoryPath(directory); + + return ( + + + + + ); + })} + {files.map(file => ( + onSelectFile(file.path)} + isActive={file.path === activeFile} + /> + ))} + + ); +} + +function collapseDirectoryPath(directory: CodeBrowserTreeDirectory) { + const pathSegments = [directory.name]; + let collapsedDirectory = directory; + + while ( + collapsedDirectory.files.length === 0 && + Object.keys(collapsedDirectory.directories).length === 1 + ) { + const [nextDirectory] = Object.values(collapsedDirectory.directories); + + pathSegments.push(nextDirectory.name); + collapsedDirectory = nextDirectory; + } + + return { + directory: collapsedDirectory, + label: pathSegments.join('/'), + }; +} diff --git a/components/Package/CodeBrowser/DownloadFileButton.tsx b/components/Package/CodeBrowser/DownloadFileButton.tsx new file mode 100644 index 00000000..aba0b28b --- /dev/null +++ b/components/Package/CodeBrowser/DownloadFileButton.tsx @@ -0,0 +1,31 @@ +import { Button } from '~/components/Button'; +import { DownloadFileIcon } from '~/components/Icons'; +import Tooltip from '~/components/Tooltip'; +import tw from '~/util/tailwind'; + +type Props = { + packageName: string; + filePath: string; +}; + +export default function DownloadFileButton({ packageName, filePath }: Props) { + return ( + + + + }> + Download + + ); +} diff --git a/components/Package/DetailsNavigation.tsx b/components/Package/DetailsNavigation.tsx index c015eab9..e9c30f0b 100644 --- a/components/Package/DetailsNavigation.tsx +++ b/components/Package/DetailsNavigation.tsx @@ -64,6 +64,7 @@ export default function DetailsNavigation({ library }: Props) { counter={library.npm?.versionsCount} path={`/package/${library.npmPkg}/versions`} /> + diff --git a/components/Package/MarkdownContentBox/MarkdownCodeBlock.tsx b/components/Package/MarkdownContentBox/MarkdownCodeBlock.tsx index 4f77f9d6..69647611 100644 --- a/components/Package/MarkdownContentBox/MarkdownCodeBlock.tsx +++ b/components/Package/MarkdownContentBox/MarkdownCodeBlock.tsx @@ -1,6 +1,7 @@ import { type Theme, useShikiHighlighter } from 'react-shiki'; import CopyButton from '~/components/Package/CopyButton'; +import { SHIKI_OPTS } from '~/util/shiki'; import tw from '~/util/tailwind'; type Props = { @@ -9,8 +10,6 @@ type Props = { theme: Theme; }; -const SHIKI_OPTS = { langAlias: { gradle: 'groovy' } } as const; - export default function MarkdownCodeBlock({ code, theme, lang }: Props) { const highlighter = useShikiHighlighter(code, lang, theme, SHIKI_OPTS); diff --git a/components/Package/PackageHeader.tsx b/components/Package/PackageHeader.tsx index 77cdcb8e..9cb79f06 100644 --- a/components/Package/PackageHeader.tsx +++ b/components/Package/PackageHeader.tsx @@ -17,9 +17,15 @@ type Props = { library: LibraryType; registryData?: NpmRegistryVersionData; rightSlot?: ReactNode; + skipDescription?: boolean; }; -export default function PackageHeader({ library, registryData, rightSlot }: Props) { +export default function PackageHeader({ + library, + registryData, + rightSlot, + skipDescription, +}: Props) { const { isSmallScreen } = useLayout(); const ghUsername = library.github.fullName.split('/')[0]; @@ -66,7 +72,7 @@ export default function PackageHeader({ library, registryData, rightSlot }: Prop {rightSlot} - + {!skipDescription && } ); } diff --git a/pages/package/[name]/[scopedName]/code.tsx b/pages/package/[name]/[scopedName]/code.tsx new file mode 100644 index 00000000..c9ae336b --- /dev/null +++ b/pages/package/[name]/[scopedName]/code.tsx @@ -0,0 +1,43 @@ +import { type NextPageContext } from 'next'; + +import ErrorScene from '~/scenes/ErrorScene'; +import PackageCodeScene from '~/scenes/PackageCodeScene'; +import { type PackageCodePageProps } from '~/types/pages'; +import { EMPTY_PACKAGE_DATA } from '~/util/Constants'; +import { getPackagePageErrorProps } from '~/util/getPackagePageErrorProps'; +import { parseQueryParams } from '~/util/queryParams'; +import { ssrFetch } from '~/util/SSRFetch'; + +export default function CodePage({ apiData, packageName, errorMessage }: PackageCodePageProps) { + if (!packageName || !apiData) { + return ; + } + + return ; +} + +export async function getServerSideProps(ctx: NextPageContext) { + const queryParams = parseQueryParams(ctx.query); + const packageName = [queryParams.name, queryParams.scopedName].join('/'); + + if (!packageName) { + return EMPTY_PACKAGE_DATA; + } + + try { + const apiResponse = await ssrFetch(`/libraries`, { search: packageName }, ctx); + + if (apiResponse.status !== 200) { + return getPackagePageErrorProps(packageName, apiResponse.status); + } + + return { + props: { + packageName, + apiData: await apiResponse.json(), + }, + }; + } catch { + return EMPTY_PACKAGE_DATA; + } +} diff --git a/pages/package/[name]/code.tsx b/pages/package/[name]/code.tsx new file mode 100644 index 00000000..53cd9ef0 --- /dev/null +++ b/pages/package/[name]/code.tsx @@ -0,0 +1,43 @@ +import { type NextPageContext } from 'next'; + +import ErrorScene from '~/scenes/ErrorScene'; +import PackageCodeScene from '~/scenes/PackageCodeScene'; +import { type PackageCodePageProps } from '~/types/pages'; +import { EMPTY_PACKAGE_DATA } from '~/util/Constants'; +import { getPackagePageErrorProps } from '~/util/getPackagePageErrorProps'; +import { parseQueryParams } from '~/util/queryParams'; +import { ssrFetch } from '~/util/SSRFetch'; + +export default function CodePage({ apiData, packageName, errorMessage }: PackageCodePageProps) { + if (!packageName || !apiData) { + return ; + } + + return ; +} + +export async function getServerSideProps(ctx: NextPageContext) { + const queryParams = parseQueryParams(ctx.query); + const packageName = queryParams.name; + + if (!packageName) { + return EMPTY_PACKAGE_DATA; + } + + try { + const apiResponse = await ssrFetch(`/libraries`, { search: packageName }, ctx); + + if (apiResponse.status !== 200) { + return getPackagePageErrorProps(packageName, apiResponse.status); + } + + return { + props: { + packageName, + apiData: await apiResponse.json(), + }, + }; + } catch { + return EMPTY_PACKAGE_DATA; + } +} diff --git a/scenes/PackageCodeScene.tsx b/scenes/PackageCodeScene.tsx new file mode 100644 index 00000000..e3e97a81 --- /dev/null +++ b/scenes/PackageCodeScene.tsx @@ -0,0 +1,194 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { type ColorValue, ScrollView, TextInput, View } from 'react-native'; +import useSWR from 'swr'; + +import { Label, P, useLayout } from '~/common/styleguide'; +import ContentContainer from '~/components/ContentContainer'; +import { FileIcon, Search as SearchIcon } from '~/components/Icons'; +import CodeBrowserContent from '~/components/Package/CodeBrowser/CodeBrowserContent'; +import CodeBrowserFileTree from '~/components/Package/CodeBrowser/CodeBrowserFileTree'; +import DetailsNavigation from '~/components/Package/DetailsNavigation'; +import NotFound from '~/components/Package/NotFound'; +import PackageHeader from '~/components/Package/PackageHeader'; +import ThreeDotsLoader from '~/components/Package/ThreeDotsLoader'; +import PageMeta from '~/components/PageMeta'; +import { type UnpkgMeta } from '~/types'; +import { type PackageCodePageProps } from '~/types/pages'; +import { buildCodeBrowserFileTree, getCodeBrowserFilePath } from '~/util/codeBrowser'; +import { TimeRange } from '~/util/datetime'; +import tw from '~/util/tailwind'; + +export default function PackageCodeScene({ apiData, packageName }: PackageCodePageProps) { + const { isSmallScreen } = useLayout(); + const inputRef = useRef(null); + + const [search, setSearch] = useState(''); + const [isInputFocused, setInputFocused] = useState(false); + const [activeFile, setActiveFile] = useState(null); + + const library = useMemo( + () => apiData.libraries.find(lib => lib.npmPkg === packageName), + [apiData.libraries, packageName] + ); + + const { data, isLoading } = useSWR( + `/api/proxy/unpkg?name=${packageName}&path=?meta`, + (url: string) => fetch(url).then(res => res.json()), + { + dedupingInterval: TimeRange.HOUR * 1000, + revalidateOnFocus: false, + } + ); + + const normalizedSearch = useMemo(() => search.trim().toLowerCase(), [search]); + + const filteredFiles = useMemo(() => { + const files = data?.files ?? []; + + if (!normalizedSearch) { + return files; + } + + return files.filter(file => + getCodeBrowserFilePath(file.path, data?.prefix).toLowerCase().includes(normalizedSearch) + ); + }, [data?.files, data?.prefix, normalizedSearch]); + + const visibleFilePaths = useMemo( + () => new Set(filteredFiles.map(file => getCodeBrowserFilePath(file.path, data?.prefix))), + [filteredFiles, data?.prefix] + ); + + useEffect(() => { + if (activeFile && !visibleFilePaths.has(activeFile)) { + setActiveFile(null); + } + }, [activeFile, visibleFilePaths]); + + const fileTree = useMemo( + () => buildCodeBrowserFileTree(filteredFiles, data?.prefix), + [filteredFiles, data?.prefix] + ); + + if (!library) { + return ; + } + + return ( + <> + + + + + + + {isLoading && ( + + + + )} + {!data && !isLoading &&

Cannot fetch package bundle code.

} + {data && !isLoading && ( + + + + + + + { + if ('key' in event) { + if (inputRef.current && event.key === 'Escape') { + if (search) { + event.preventDefault(); + setSearch(''); + } else { + inputRef.current.blur(); + } + } + } + }} + onFocus={() => setInputFocused(true)} + onBlur={() => setInputFocused(false)} + onChangeText={setSearch} + placeholder="Search files..." + style={[ + tw`font-sans flex h-11 flex-1 rounded-none bg-white p-1 pl-10 text-sm text-black -outline-offset-2 dark:bg-dark dark:text-white`, + isSmallScreen ? tw`rounded-t-xl` : tw`rounded-tl-xl`, + ]} + value={search} + placeholderTextColor={tw`text-palette-gray4`.color as ColorValue} + /> + + + {filteredFiles.length > 0 ? ( + + ) : ( + + + + )} + + + + {activeFile ? ( + file.path === `${data.prefix}${activeFile}` + )} + /> + ) : ( + + +

+ Select file to preview from the list on the left. +

+
+ )} +
+
+ )} +
+
+
+ + ); +} diff --git a/scenes/PackageOverviewScene.tsx b/scenes/PackageOverviewScene.tsx index 1e2b525e..d3901608 100644 --- a/scenes/PackageOverviewScene.tsx +++ b/scenes/PackageOverviewScene.tsx @@ -60,7 +60,7 @@ export default function PackageOverviewScene({ path="package" /> - + - + - + - + - + {hasVersionDownloads && npmDownloads ? ( <> diff --git a/styles/shiki/rnd-dark.json b/styles/shiki/rnd-dark.json index 6e5378f8..064b67ca 100644 --- a/styles/shiki/rnd-dark.json +++ b/styles/shiki/rnd-dark.json @@ -283,7 +283,12 @@ } }, { - "scope": ["entity.name", "meta.export.default", "meta.definition.variable"], + "scope": [ + "entity.name", + "meta.export.default", + "meta.definition.variable", + "storage.type.annotation" + ], "settings": { "foreground": "#ffa657" } @@ -291,15 +296,21 @@ { "scope": [ "punctuation.terminator.statement", + "punctuation.bracket", "punctuation.definition", + "punctuation.section.arguments", + "punctuation.section.angle-brackets", + "punctuation.section.block", "punctuation.section.embedded.begin", "punctuation.section.embedded.end", "punctuation.section.group.begin", "punctuation.section.group.end", "punctuation.section.parameters.begin", "punctuation.section.parameters.end", + "punctuation.section.parens", + "punctuation.section.function", + "punctuation.section.scope", "punctuation.separator", - "meta.block", "meta.brace.round", "meta.brace.square", "meta.embedded.expression", @@ -335,13 +346,19 @@ } }, { - "scope": ["keyword.soft", "keyword.hard", "keyword.operator", "keyword.control"], + "scope": [ + "keyword.soft", + "keyword.hard", + "keyword.operator", + "keyword.control", + "keyword.other" + ], "settings": { "foreground": "#ff7b72" } }, { - "scope": ["storage", "storage.type"], + "scope": ["storage", "storage.type", "storage.modifier.attribute"], "settings": { "foreground": "#ff7b72" } @@ -377,7 +394,7 @@ } }, { - "scope": "variable.other", + "scope": ["variable.other", "meta.function.definition"], "settings": { "foreground": "#e6edf3" } diff --git a/styles/shiki/rnd-light.json b/styles/shiki/rnd-light.json index b164c826..dd37c680 100644 --- a/styles/shiki/rnd-light.json +++ b/styles/shiki/rnd-light.json @@ -268,7 +268,12 @@ } }, { - "scope": ["entity.name", "meta.export.default", "meta.definition.variable"], + "scope": [ + "entity.name", + "meta.export.default", + "meta.definition.variable", + "storage.type.annotation" + ], "settings": { "foreground": "#953800" } @@ -276,15 +281,21 @@ { "scope": [ "punctuation.terminator.statement", + "punctuation.bracket", "punctuation.definition", + "punctuation.section.arguments", + "punctuation.section.angle-brackets", + "punctuation.section.block", "punctuation.section.embedded.begin", "punctuation.section.embedded.end", "punctuation.section.group.begin", "punctuation.section.group.end", "punctuation.section.parameters.begin", "punctuation.section.parameters.end", + "punctuation.section.parens", + "punctuation.section.function", + "punctuation.section.scope", "punctuation.separator", - "meta.block", "meta.brace.round", "meta.brace.square", "meta.embedded.expression", @@ -320,13 +331,19 @@ } }, { - "scope": ["keyword.soft", "keyword.hard", "keyword.operator", "keyword.control"], + "scope": [ + "keyword.soft", + "keyword.hard", + "keyword.operator", + "keyword.control", + "keyword.other" + ], "settings": { "foreground": "#cf222e" } }, { - "scope": ["storage", "storage.type"], + "scope": ["storage", "storage.type", "storage.modifier.attribute"], "settings": { "foreground": "#cf222e" } @@ -362,7 +379,7 @@ } }, { - "scope": "variable.other", + "scope": ["variable.other", "meta.function.definition"], "settings": { "foreground": "#1f2328" } diff --git a/styles/styles.css b/styles/styles.css index c4fc2c50..0b8ed39f 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -124,6 +124,10 @@ border-radius: 3px; } +* { + scrollbar-width: thin; +} + html, body { background: var(--overscroll-background); @@ -762,3 +766,36 @@ select { background-color: var(--table-header-background); } } + +/* CODE BROWSER */ + +#codeBrowser { + #codeBrowserList { + background-color: var(--table-alt-row); + scrollbar-color: var(--icon) var(--gray-1); + scrollbar-gutter: stable; + } + + pre { + margin-block: 0; + padding: 16px; + overflow: auto; + height: 100%; + max-width: 100%; + overflow-wrap: anywhere; + white-space: pre-wrap; + line-height: 16px; + } +} + +:root.dark { + #codeBrowser { + #codeBrowserList { + scrollbar-color: var(--icon) var(--table-alt-row); + } + + pre { + scrollbar-color: var(--icon) #0d1117; + } + } +} diff --git a/types/index.ts b/types/index.ts index d93c132a..60cef334 100644 --- a/types/index.ts +++ b/types/index.ts @@ -357,3 +357,27 @@ export type GitHubUser = { url: string; user_view_type: string; }; + +export type CodeBrowserTreeFile = { + name: string; + path: string; +}; + +export type CodeBrowserTreeDirectory = { + name: string; + path: string; + directories: Record; + files: CodeBrowserTreeFile[]; +}; + +export type UnpkgMeta = { + files: { + integrity: string; + path: string; + size: number; + type: string; + }[]; + package: string; + prefix: string; + version: string; +}; diff --git a/types/pages.ts b/types/pages.ts index fabab5ef..30b399eb 100644 --- a/types/pages.ts +++ b/types/pages.ts @@ -62,3 +62,11 @@ export type PackageScorePageProps = { }; errorMessage?: string; }; + +export type PackageCodePageProps = { + packageName: string; + apiData: { + libraries: LibraryType[]; + }; + errorMessage?: string; +}; diff --git a/util/codeBrowser.ts b/util/codeBrowser.ts new file mode 100644 index 00000000..14fee631 --- /dev/null +++ b/util/codeBrowser.ts @@ -0,0 +1,165 @@ +import { type CodeBrowserTreeDirectory, type UnpkgMeta } from '~/types'; + +export const PREVIEW_DISABLED_FILES = [ + 'a', + 'aar', + 'bin', + 'caffemodel', + 'car', + 'class', + 'dex', + 'emd', + 'exe', + 'extrieve', + 'jar', + 'keystore', + 'gz', + 'mat', + 'nib', + 'otf', + 'pb', + 'pbd', + 'rc', + 'raw', + 'so', + 'strings', + 'swiftdoc', + 'swiftsourceinfo', + 'tfl', + 'tflite', + 'tgz', + 'ttf', + 'xcuserstate', +]; + +export const IMAGE_FILES = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']; + +export const FILE_WARNINGS = [ + { + message: 'This file should not be part of the bundle and can be safely ignored.', + fileNames: [ + '*.iml', + '*.keystore', + '.all-contributorsrc', + '.babelrc', + '.buckconfig', + '.clang-format-ignore', + '.eslintrc.js', + '.flowconfig', + '.gitattributes', + '.gitkeep', + '.gitmodules', + '.licence-config.yaml', + '.prettierignore', + '.project', + '.travis.yml', + '.watchmanconfig', + 'babel.config.js', + 'eslint.config*.js', + 'gradle-wrapper.jar', + 'gradlew', + 'gradlew.bat', + 'jest*.config.js', + 'local.properties', + 'metro.config.js', + 'nitro.json', + 'publish.gradle', + '*prettierrc.js', + 'proguard-rules.pro', + 'react-native.config.js', + 'rollup.config.js', + 'settings.gradle', + 'spotless.gradle', + 'tsconfig*.json', + '*.tsbuildinfo', + 'tsup.config.ts', + 'turbo.json', + 'typedoc.json', + // LOCK FILES + 'yarn.lock', + 'pnpm-lock.yaml', + 'bun.lock', + 'bun.lockb', + 'package-lock.json', + ], + }, +]; + +const FILE_WARNING_MATCHERS = FILE_WARNINGS.map(warning => ({ + ...warning, + matchers: warning.fileNames.map(createFileWarningMatcher), +})); + +export function getFileWarning(fileName: string) { + return FILE_WARNING_MATCHERS.find(warning => warning.matchers.some(matcher => matcher(fileName))); +} + +export function getCodeBrowserFilePath(path: string, prefix?: string) { + return prefix ? path.replace(prefix, '') : path; +} + +export function buildCodeBrowserFileTree( + files: UnpkgMeta['files'], + prefix?: string +): CodeBrowserTreeDirectory { + const root = createCodeBrowserTreeDirectory('', ''); + + files.forEach(file => { + const relativePath = getCodeBrowserFilePath(file.path, prefix); + const pathSegments = relativePath.split('/').filter(Boolean); + + if (pathSegments.length === 0) { + return; + } + + const fileName = pathSegments.pop(); + + if (!fileName) { + return; + } + + let currentDirectory = root; + + pathSegments.forEach(segment => { + const nextPath = currentDirectory.path ? `${currentDirectory.path}/${segment}` : segment; + + currentDirectory.directories[segment] ??= createCodeBrowserTreeDirectory(segment, nextPath); + currentDirectory = currentDirectory.directories[segment]; + }); + + currentDirectory.files.push({ + name: fileName, + path: relativePath, + }); + }); + + return root; +} + +function createCodeBrowserTreeDirectory(name: string, path: string): CodeBrowserTreeDirectory { + return { + name, + path, + directories: {}, + files: [], + }; +} + +function createFileWarningMatcher(pattern: string) { + if (!pattern.includes('*')) { + return (fileName: string) => fileName === pattern; + } + + const regex = new RegExp( + `^${pattern + .split('*') + .map(pathSegment => escapeRegularExpression(pathSegment)) + .join('.*')}$` + ); + + return (fileName: string) => regex.test(fileName); +} + +function escapeRegularExpression(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/util/shiki.ts b/util/shiki.ts new file mode 100644 index 00000000..b973788c --- /dev/null +++ b/util/shiki.ts @@ -0,0 +1,31 @@ +export const SHIKI_OPTS = { + langAlias: { + appxmanifest: 'xml', + cc: 'cpp', + cfg: 'ini', + cl: 'cpp', + filters: 'xml', + flow: 'tsx', + gradle: 'groovy', + h: 'objective-cpp', + hpp: 'cpp', + iml: 'xml', + map: 'json', + m: 'objective-c', + md: 'mdx', + mm: 'objective-cpp', + plist: 'xml', + podspec: 'ruby', + pom: 'xml', + pro: 'properties', + props: 'xml', + sln: 'ini', + svg: 'xml', + vcxproj: 'xml', + targets: 'xml', + tsbuildinfo: 'json', + xaml: 'xml', + xcprivacy: 'xml', + xcworkspacedata: 'xml', + }, +} as const;