diff --git a/.gitignore b/.gitignore index 7b4e9649..90e71587 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules .next .vscode/.chrome coverage/ +static/media/ diff --git a/jest.setup.js b/jest.setup.js index 6a694090..e61fd21a 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1 +1,8 @@ import "@testing-library/jest-dom/extend-expect"; + +// Override the react.cache method to avoid caching in tests +jest.mock("react", () => { + const React = jest.requireActual("react"); + React.cache = (fn) => fn; + return React; +}); diff --git a/next.config.js b/next.config.js index 7368c90d..2473c082 100644 --- a/next.config.js +++ b/next.config.js @@ -1,7 +1,17 @@ /** @type {import('next').NextConfig} */ module.exports = { reactStrictMode: true, - compiler: { - emotion: true, + // Does not work with appDir + // https://beta.nextjs.org/docs/styling/css-in-js + // compiler: { + // emotion: true, + // }, + experimental: { + serverComponentsExternalPackages: [ + "libnpmdiff", + "npm-package-arg", + "pacote", + ], + appDir: true, }, }; diff --git a/package-lock.json b/package-lock.json index 2f754aaa..57ca80ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@emotion/styled": "^11.10.6", "@vercel/analytics": "^0.1.11", "downshift": "^7.6.0", + "gitdiff-parser": "^0.3.1", "jest": "^29.5.0", "libnpmdiff": "^5.0.15", "next": "^13.3.0", @@ -6402,6 +6403,11 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gitdiff-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/gitdiff-parser/-/gitdiff-parser-0.3.1.tgz", + "integrity": "sha512-YQJnY8aew65id8okGxKCksH3efDCJ9HzV7M9rsvd65habf39Pkh4cgYJ27AaoDMqo1X98pgNJhNMrm/kpV7UVQ==" + }, "node_modules/glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", diff --git a/package.json b/package.json index d2964f03..f4147f06 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@emotion/styled": "^11.10.6", "@vercel/analytics": "^0.1.11", "downshift": "^7.6.0", + "gitdiff-parser": "^0.3.1", "jest": "^29.5.0", "libnpmdiff": "^5.0.15", "next": "^13.3.0", diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png deleted file mode 100644 index 3aa23596..00000000 Binary files a/public/favicon-16x16.png and /dev/null differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png deleted file mode 100644 index ca73a18c..00000000 Binary files a/public/favicon-32x32.png and /dev/null differ diff --git a/src/components/ErrorBox.tsx b/src/app/[...parts]/_error/ErrorBox.tsx similarity index 71% rename from src/components/ErrorBox.tsx rename to src/app/[...parts]/_error/ErrorBox.tsx index 74269d65..03ad0d08 100644 --- a/src/components/ErrorBox.tsx +++ b/src/app/[...parts]/_error/ErrorBox.tsx @@ -1,12 +1,12 @@ -import { Code, forwardRef } from "@chakra-ui/react"; -import BorderBox, { BorderBoxProps } from "./theme/BorderBox"; +import { Code, forwardRef, VStack } from "@chakra-ui/react"; +import BorderBox, { BorderBoxProps } from "^/components/BorderBox"; export interface ErrorBoxProps extends BorderBoxProps {} const ErrorBox = forwardRef((props, ref) => { return ( { + const { result, time } = await measuredPromise(bundlephobia(specs)); + + if (result == null) { + console.warn(`${name} result is null`, { specs }); + return null; + } + + if (result === TIMED_OUT) { + console.warn(`${name} timed out`, { specs }); + return null; + } + + console.log(name, { specs, time }); + + return ( + } + a={a} + b={b} + sizeRows={[ + { + name: "Size", + a: { + bytes: result.a.size, + }, + b: { + bytes: result.b.size, + }, + }, + { + name: "Gzip", + a: { + bytes: result.a.gzip, + }, + b: { + bytes: result.b.gzip, + }, + }, + { + name: "Dependencies", + a: { + count: result.a.dependencyCount, + }, + b: { + count: result.b.dependencyCount, + }, + }, + ]} + /> + ); +}; + +export default BundlephobiaDiff; + +export const BundlephobiaDiffSkeleton = () => ( + } + sizeRows={[ + { + name: "Size", + a: 42, + b: 84, + }, + { + name: "Gzip", + a: 42, + b: 84, + }, + { + name: "Dependencies", + a: 16, + b: 32, + }, + ]} + /> +); diff --git a/src/components/DiffIntro/BundlePhobiaFlags/BundlePhobiaFlags.tsx b/src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/BundlePhobiaFlags.tsx similarity index 99% rename from src/components/DiffIntro/BundlePhobiaFlags/BundlePhobiaFlags.tsx rename to src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/BundlePhobiaFlags.tsx index cc2a1acf..feb67a43 100644 --- a/src/components/DiffIntro/BundlePhobiaFlags/BundlePhobiaFlags.tsx +++ b/src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/BundlePhobiaFlags.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Code, forwardRef, HStack, StackProps, Text } from "@chakra-ui/react"; import { ReactNode } from "react"; import { diff --git a/src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/BundlePhobiaFlagsSkeleton.tsx b/src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/BundlePhobiaFlagsSkeleton.tsx new file mode 100644 index 00000000..8fadbf0c --- /dev/null +++ b/src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/BundlePhobiaFlagsSkeleton.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { HStack } from "@chakra-ui/react"; +import { FlagSkeleton } from "./Flag"; + +const BundlephobiaFlagsSkeleton = () => ( + + + + +); + +export default BundlephobiaFlagsSkeleton; diff --git a/src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/Flag.tsx b/src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/Flag.tsx new file mode 100644 index 00000000..e8486e5a --- /dev/null +++ b/src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/Flag.tsx @@ -0,0 +1,55 @@ +import { + forwardRef, + Skeleton, + Tag, + TagLabel, + TagLeftIcon, + TagProps, +} from "@chakra-ui/react"; +import { ElementType, ReactNode } from "react"; +import Tooltip from "^/components/Tooltip"; +import TreeshakeIcon from "./assets/TreeshakeIcon"; + +interface FlagProps extends TagProps { + icon: ElementType; + label: string; + tooltip?: ReactNode; + colorScheme?: undefined | "green" | "red"; +} + +const Flag = forwardRef( + ({ label, icon, tooltip, colorScheme, ...props }, ref) => { + const tag = ( + + + {label} + + ); + + if (tooltip == null) { + return tag; + } + + return ( + + {tag} + + ); + }, +); + +export default Flag; + +export const FlagSkeleton = () => ( + +); diff --git a/src/components/DiffIntro/BundlePhobiaFlags/assets/SideeffectIcon.tsx b/src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/assets/SideeffectIcon.tsx similarity index 100% rename from src/components/DiffIntro/BundlePhobiaFlags/assets/SideeffectIcon.tsx rename to src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/assets/SideeffectIcon.tsx diff --git a/src/components/DiffIntro/BundlePhobiaFlags/assets/TreeshakeIcon.tsx b/src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/assets/TreeshakeIcon.tsx similarity index 100% rename from src/components/DiffIntro/BundlePhobiaFlags/assets/TreeshakeIcon.tsx rename to src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/assets/TreeshakeIcon.tsx diff --git a/src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/index.ts b/src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/index.ts new file mode 100644 index 00000000..c37a84a6 --- /dev/null +++ b/src/app/[...parts]/_page/DiffIntro/BundlePhobiaFlags/index.ts @@ -0,0 +1,5 @@ +import BundlephobiaFlags, { BundlephobiaFlagsProps } from "./BundlePhobiaFlags"; +import BundlephobiaFlagsSkeleton from "./BundlePhobiaFlagsSkeleton"; + +export default BundlephobiaFlags; +export { BundlephobiaFlagsSkeleton, type BundlephobiaFlagsProps }; diff --git a/src/components/DiffIntro/Command.tsx b/src/app/[...parts]/_page/DiffIntro/Command.tsx similarity index 93% rename from src/components/DiffIntro/Command.tsx rename to src/app/[...parts]/_page/DiffIntro/Command.tsx index a42c7e32..1dc024c8 100644 --- a/src/components/DiffIntro/Command.tsx +++ b/src/app/[...parts]/_page/DiffIntro/Command.tsx @@ -1,13 +1,13 @@ import { Code } from "@chakra-ui/react"; import { forwardRef } from "@chakra-ui/system"; -import DiffOptions from "^/lib/DiffOptions"; +import { NpmDiffOptions } from "^/lib/npmDiff"; export interface CommandProps { aName: string; aVersion: string; bName: string; bVersion: string; - options: DiffOptions; + options: NpmDiffOptions; } function toKebabCase(input: string): string { diff --git a/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx b/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx new file mode 100644 index 00000000..b2a53450 --- /dev/null +++ b/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { + Box, + Code, + Flex, + FlexProps, + forwardRef, + Heading, + Text, +} from "@chakra-ui/react"; +import { ReactNode } from "react"; +import { NpmDiffOptions } from "^/lib/npmDiff"; +import SimplePackageSpec from "^/lib/SimplePackageSpec"; +import contentVisibility from "^/lib/utils/contentVisibility"; +import Halfs from "./Halfs"; +import Options from "./Options"; +import SpecBox from "./SpecBox"; + +export interface DiffIntroProps extends FlexProps { + a: SimplePackageSpec; + b: SimplePackageSpec; + services: ReactNode; + options: NpmDiffOptions; +} + +const DiffIntro = forwardRef( + ({ a, b, services, options, ...props }, ref) => { + if (a.name == null) { + a.name = "ERROR"; + } + if (b.name == null) { + b.name = "ERROR"; + } + + return ( + + + Comparing + } + center={ + + {/* Center column */} + ... + + } + right={} + /> + + {services} + npm diff + + {/* */} + + ); + }, +); + +export default DiffIntro; diff --git a/src/components/DiffIntro/Halfs.tsx b/src/app/[...parts]/_page/DiffIntro/Halfs.tsx similarity index 76% rename from src/components/DiffIntro/Halfs.tsx rename to src/app/[...parts]/_page/DiffIntro/Halfs.tsx index 456635d5..d10e606d 100644 --- a/src/components/DiffIntro/Halfs.tsx +++ b/src/app/[...parts]/_page/DiffIntro/Halfs.tsx @@ -1,14 +1,15 @@ -import { Flex } from "@chakra-ui/react"; +import { Flex, FlexProps } from "@chakra-ui/react"; import { forwardRef } from "@chakra-ui/system"; import { ReactNode } from "react"; -interface ComparisonViewProps { +export interface HalfsProps + extends Omit { left: ReactNode; center?: ReactNode; right: ReactNode; } -const Halfs = forwardRef( +const Halfs = forwardRef( ({ left, center, right, ...props }, ref) => ( diff --git a/src/components/DiffIntro/Options.tsx b/src/app/[...parts]/_page/DiffIntro/Options.tsx similarity index 89% rename from src/components/DiffIntro/Options.tsx rename to src/app/[...parts]/_page/DiffIntro/Options.tsx index dc7a785e..e7ae2e22 100644 --- a/src/components/DiffIntro/Options.tsx +++ b/src/app/[...parts]/_page/DiffIntro/Options.tsx @@ -1,10 +1,10 @@ import { Code, Heading, Text } from "@chakra-ui/react"; import { forwardRef } from "@chakra-ui/system"; -import { BorderBox } from "^/components/theme"; -import DiffOptions from "^/lib/DiffOptions"; +import BorderBox from "^/components/BorderBox"; +import { NpmDiffOptions } from "^/lib/npmDiff"; interface OptionsProps { - options: DiffOptions; + options: NpmDiffOptions; } const Options = forwardRef( diff --git a/src/components/DiffIntro/ServiceLink.tsx b/src/app/[...parts]/_page/DiffIntro/ServiceLink.tsx similarity index 66% rename from src/components/DiffIntro/ServiceLink.tsx rename to src/app/[...parts]/_page/DiffIntro/ServiceLink.tsx index 7d4e949c..2131216f 100644 --- a/src/components/DiffIntro/ServiceLink.tsx +++ b/src/app/[...parts]/_page/DiffIntro/ServiceLink.tsx @@ -1,23 +1,19 @@ import { forwardRef, IconButton, Link, LinkProps } from "@chakra-ui/react"; import { Service } from "^/lib/Services"; +import SimplePackageSpec from "^/lib/SimplePackageSpec"; import ServiceIcon from "../ServiceIcon"; import ServiceTooltip from "./ServiceTooltip"; export interface ServiceLinkProps extends LinkProps { service: Service; - packageName: string; - packageVersion: string; + pkg: SimplePackageSpec; } const ServiceLink = forwardRef( - ({ service, packageName, packageVersion, ...props }, ref) => ( - + ({ service, pkg, ...props }, ref) => ( + ( - ({ packageName, packageVersion, ...props }, ref) => ( + ({ pkg, ...props }, ref) => ( - {Services.map((service) => ( - + {Object.values(Services).map((service) => ( + ))} ), diff --git a/src/components/DiffIntro/ServiceTooltip.tsx b/src/app/[...parts]/_page/DiffIntro/ServiceTooltip.tsx similarity index 62% rename from src/components/DiffIntro/ServiceTooltip.tsx rename to src/app/[...parts]/_page/DiffIntro/ServiceTooltip.tsx index 6a896f70..e0d015f2 100644 --- a/src/components/DiffIntro/ServiceTooltip.tsx +++ b/src/app/[...parts]/_page/DiffIntro/ServiceTooltip.tsx @@ -1,15 +1,17 @@ -import { Code, forwardRef, Text } from "@chakra-ui/react"; -import { B, Tooltip, TooltipProps } from "^/components/theme"; +import { forwardRef, Text } from "@chakra-ui/react"; +import B from "^/components/B"; +import Pkg from "^/components/Pkg"; +import Tooltip, { TooltipProps } from "^/components/Tooltip"; import { Service } from "^/lib/Services"; +import SimplePackageSpec from "^/lib/SimplePackageSpec"; export interface ServiceTooltipProps extends TooltipProps { - packageName: string; - packageVersion: string; + pkg: SimplePackageSpec; serviceName: Service["name"]; } const ServiceTooltip = forwardRef( - ({ packageName, packageVersion, serviceName, ...props }, ref) => { + ({ pkg, serviceName, ...props }, ref) => { return ( ( label={ <> - View{" "} - - {packageName}@{packageVersion} - + View on {serviceName} diff --git a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx new file mode 100644 index 00000000..6e1ab24b --- /dev/null +++ b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx @@ -0,0 +1,19 @@ +import { Box, BoxProps, forwardRef } from "@chakra-ui/react"; +import Pkg from "^/components/Pkg"; +import SimplePackageSpec from "^/lib/SimplePackageSpec"; +import ServiceLinks from "./ServiceLinks"; + +interface SpecBoxProps extends BoxProps { + pkg: SimplePackageSpec; +} + +const SpecBox = forwardRef(({ pkg, ...props }, ref) => ( + + + + + + +)); + +export default SpecBox; diff --git a/src/components/DiffIntro/index.ts b/src/app/[...parts]/_page/DiffIntro/index.ts similarity index 100% rename from src/components/DiffIntro/index.ts rename to src/app/[...parts]/_page/DiffIntro/index.ts diff --git a/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffFile.skeleton.tsx b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffFile.skeleton.tsx new file mode 100644 index 00000000..0e937585 --- /dev/null +++ b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffFile.skeleton.tsx @@ -0,0 +1,54 @@ +import { Box, Skeleton } from "@chakra-ui/react"; +import CollapsableBorderBox from "^/components/CollapsableBorderBox"; +import contentVisibility from "^/lib/utils/contentVisibility"; +import { DiffFileHeaderSkeleton } from "./DiffFileHeader"; +import { Decoration } from "./react-diff-view"; + +const FakeCodeRow = ({ + length, + indent, +}: { + length: number; + indent: number; +}) => ( + +); + +export default function DiffFileSkeleton() { + return ( + } + > + + + + + + + + + + + + + + ); +} diff --git a/src/components/Diff/DiffFile/DiffFile.tsx b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffFile.tsx similarity index 87% rename from src/components/Diff/DiffFile/DiffFile.tsx rename to src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffFile.tsx index 7c3f9640..9a1d863b 100644 --- a/src/components/Diff/DiffFile/DiffFile.tsx +++ b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffFile.tsx @@ -1,25 +1,24 @@ import { forwardRef } from "@chakra-ui/react"; -import type { Result as NpaResult } from "npm-package-arg"; import { useCallback, useMemo, useState } from "react"; import type { ChangeData, FileData, ViewType } from "react-diff-view"; import "react-diff-view/style/index.css"; -import { Diff } from "^/components/react-diff-view"; -import { - CollapsableBorderBox, +import CollapsableBorderBox, { CollapsableBorderBoxProps, -} from "^/components/theme"; +} from "^/components/CollapsableBorderBox"; +import SimplePackageSpec from "^/lib/SimplePackageSpec"; import contentVisibility from "^/lib/utils/contentVisibility"; import countChanges from "^/lib/utils/countChanges"; import DiffFileHeader from "./DiffFileHeader"; import DiffHunk from "./DiffHunk"; import DiffPlaceholder from "./DiffPlaceholder"; +import { Diff } from "./react-diff-view"; const FILES_TO_RENDER = 2 ** 6; const CHANGES_TO_RENDER = 2 ** 7; -interface DiffFileProps extends CollapsableBorderBoxProps { - a: NpaResult; - b: NpaResult; +export interface DiffFileProps extends CollapsableBorderBoxProps { + a: SimplePackageSpec; + b: SimplePackageSpec; file: FileData; viewType: ViewType; index: number; @@ -46,9 +45,11 @@ const DiffFile = forwardRef( ); const generateAnchorID = useCallback( - ({ lineNumber, oldLineNumber }: ChangeData) => + (change: ChangeData) => `${type === "delete" ? oldPath : newPath}-L${ - lineNumber ?? oldLineNumber + change.type !== "normal" + ? change.lineNumber + : change.oldLineNumber }`, [type, oldPath, newPath], ); diff --git a/src/components/Diff/DiffFile/DiffFileHeader.tsx b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffFileHeader.tsx similarity index 56% rename from src/components/Diff/DiffFile/DiffFileHeader.tsx rename to src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffFileHeader.tsx index 4a580b0a..29c5f9c8 100644 --- a/src/components/Diff/DiffFile/DiffFileHeader.tsx +++ b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffFileHeader.tsx @@ -3,27 +3,29 @@ import { Code, Heading, HStack, + Skeleton, StackProps, - Text, + VStack, } from "@chakra-ui/react"; -import type { Result as NpaResult } from "npm-package-arg"; import { FunctionComponent } from "react"; import type { FileData } from "react-diff-view"; -import ServiceIcon from "^/components/ServiceIcon"; -import { Tooltip } from "^/components/theme"; +import Span from "^/components/Span"; +import Tooltip from "^/components/Tooltip"; import { unpkg } from "^/lib/Services"; +import SimplePackageSpec from "^/lib/SimplePackageSpec"; import type { CountedChanges } from "^/lib/utils/countChanges"; +import ServiceIcon from "../../../ServiceIcon"; export interface DiffFileHeaderProps extends StackProps { - a: NpaResult; - b: NpaResult; + a: SimplePackageSpec; + b: SimplePackageSpec; file: FileData; countedChanges: CountedChanges; } const DiffFileHeader: FunctionComponent = ({ - a: { name: aName, rawSpec: aVersion }, - b: { name: bName, rawSpec: bVersion }, + a, + b, file: { type, oldPath, newPath }, countedChanges: { additions, deletions }, children, @@ -37,14 +39,14 @@ const DiffFileHeader: FunctionComponent = ({ additions + deletions } changes: ${additions} additions & ${deletions} deletions`} > - - + + +++{additions} - - + + ---{deletions} - - + + = ({ as="a" href={ type === "delete" - ? unpkg.url(aName, aVersion, oldPath) - : unpkg.url(bName, bVersion, newPath) + ? unpkg.url(a, oldPath) + : unpkg.url(b, newPath) } rel="noopener noreferrer" target="_blank" @@ -75,3 +77,28 @@ const DiffFileHeader: FunctionComponent = ({ ); export default DiffFileHeader; + +export function DiffFileHeaderSkeleton() { + return ( + + + + + + +++0 + + + ---0 + + + + + + ); +} diff --git a/src/components/Diff/DiffFile/DiffHunk.tsx b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffHunk.tsx similarity index 92% rename from src/components/Diff/DiffFile/DiffHunk.tsx rename to src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffHunk.tsx index 313a95d3..72c36e3c 100644 --- a/src/components/Diff/DiffFile/DiffHunk.tsx +++ b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffHunk.tsx @@ -1,8 +1,8 @@ import { Text } from "@chakra-ui/react"; import { FunctionComponent } from "react"; import { HunkData } from "react-diff-view"; -import { Decoration, Hunk } from "^/components/react-diff-view"; import contentVisibility from "^/lib/utils/contentVisibility"; +import { Decoration, Hunk } from "./react-diff-view"; interface DiffHunkProps { hunk: HunkData; diff --git a/src/components/Diff/DiffFile/DiffPlaceholder.tsx b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffPlaceholder.tsx similarity index 100% rename from src/components/Diff/DiffFile/DiffPlaceholder.tsx rename to src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/DiffPlaceholder.tsx diff --git a/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/index.ts b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/index.ts new file mode 100644 index 00000000..1035b1b2 --- /dev/null +++ b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/index.ts @@ -0,0 +1,5 @@ +import DiffFile, { DiffFileProps } from "./DiffFile"; +import DiffFileSkeleton from "./DiffFile.skeleton"; + +export default DiffFile; +export { type DiffFileProps, DiffFileSkeleton }; diff --git a/src/components/react-diff-view.tsx b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/react-diff-view.tsx similarity index 100% rename from src/components/react-diff-view.tsx rename to src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFile/react-diff-view.tsx diff --git a/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFiles.skeleton.tsx b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFiles.skeleton.tsx new file mode 100644 index 00000000..229cff50 --- /dev/null +++ b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFiles.skeleton.tsx @@ -0,0 +1,10 @@ +import { Box } from "@chakra-ui/react"; +import { DiffFileSkeleton } from "./DiffFile"; + +export default function DiffFilesSkeleton() { + return ( + + + + ); +} diff --git a/src/components/Diff/DiffFiles.tsx b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFiles.tsx similarity index 65% rename from src/components/Diff/DiffFiles.tsx rename to src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFiles.tsx index 2f9afb66..e0a89cdc 100644 --- a/src/components/Diff/DiffFiles.tsx +++ b/src/app/[...parts]/_page/NpmDiff/DiffFiles/DiffFiles.tsx @@ -1,22 +1,19 @@ import { Box } from "@chakra-ui/react"; -import type { Result as NpaResult } from "npm-package-arg"; import { FunctionComponent } from "react"; -import { FileData, ViewType } from "react-diff-view"; +import { FileData } from "react-diff-view"; +import SimplePackageSpec from "^/lib/SimplePackageSpec"; +import useViewType from "^/lib/utils/useViewType"; import DiffFileComponent from "./DiffFile"; -interface DiffFilesProps { - a: NpaResult; - b: NpaResult; +export interface DiffFilesProps { + a: SimplePackageSpec; + b: SimplePackageSpec; files: FileData[]; - viewType: ViewType; } -const DiffFiles: FunctionComponent = ({ - a, - b, - files, - viewType, -}) => { +const DiffFiles: FunctionComponent = ({ a, b, files }) => { + const viewType = useViewType(); + return ( {files.map((file, index) => ( diff --git a/src/app/[...parts]/_page/NpmDiff/DiffFiles/index.ts b/src/app/[...parts]/_page/NpmDiff/DiffFiles/index.ts new file mode 100644 index 00000000..0208de78 --- /dev/null +++ b/src/app/[...parts]/_page/NpmDiff/DiffFiles/index.ts @@ -0,0 +1,5 @@ +import DiffFiles, { DiffFilesProps } from "./DiffFiles"; +import DiffFilesSkeleton from "./DiffFiles.skeleton"; + +export { type DiffFilesProps, DiffFilesSkeleton }; +export default DiffFiles; diff --git a/src/app/[...parts]/_page/NpmDiff/NoDiff.tsx b/src/app/[...parts]/_page/NpmDiff/NoDiff.tsx new file mode 100644 index 00000000..8094272e --- /dev/null +++ b/src/app/[...parts]/_page/NpmDiff/NoDiff.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Box, Code, Heading, Text } from "@chakra-ui/react"; +import SimplePackageSpec from "^/lib/SimplePackageSpec"; + +export interface NoDiffProps { + a: SimplePackageSpec; + b: SimplePackageSpec; +} + +const NoDiff = ({ a, b }: NoDiffProps) => ( + + 📦🔃 + + There's nothing to compare! + + + {a.name}@{a.version} + {" "} + and{" "} + + {b.name}@{b.version} + {" "} + are identical. + +); + +export default NoDiff; diff --git a/src/app/[...parts]/_page/NpmDiff/NpmDiff.client.tsx b/src/app/[...parts]/_page/NpmDiff/NpmDiff.client.tsx new file mode 100644 index 00000000..98993ca3 --- /dev/null +++ b/src/app/[...parts]/_page/NpmDiff/NpmDiff.client.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { HStack, useBreakpointValue } from "@chakra-ui/react"; +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; +import { ViewType } from "react-diff-view"; +import B from "^/components/B"; +import Span from "^/components/Span"; +import { DIFF_TYPE_PARAM_NAME } from "../paramNames"; +import DiffFiles, { DiffFilesProps } from "./DiffFiles"; +import ViewTypeSwitch from "./ViewTypeSwitch"; + +export interface NpmDiffClientProps extends Omit { + additions: number; + deletions: number; +} + +const NpmDiffClient = ({ + additions, + deletions, + ...props +}: NpmDiffClientProps) => ( + <> + + + Showing {props.files.length} changed files with{" "} + {additions} additions and {deletions} deletions + + {/* Wrap in suspense because components use dynamic function https://beta.nextjs.org/docs/rendering/static-and-dynamic-rendering#using-dynamic-functions */} + + + + + + +); + +export default NpmDiffClient; diff --git a/src/app/[...parts]/_page/NpmDiff/NpmDiff.skeleton.tsx b/src/app/[...parts]/_page/NpmDiff/NpmDiff.skeleton.tsx new file mode 100644 index 00000000..9bf94269 --- /dev/null +++ b/src/app/[...parts]/_page/NpmDiff/NpmDiff.skeleton.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Box, BoxProps, HStack, Skeleton } from "@chakra-ui/react"; +import { Suspense } from "react"; +import { DiffFilesSkeleton } from "./DiffFiles"; +import ViewTypeSwitch from "./ViewTypeSwitch"; + +export interface NpmDiffSkeletonProps extends BoxProps {} + +const NpmDiffSkeleton = (props: NpmDiffSkeletonProps) => { + return ( + + + + + + + + + + ); +}; + +export default NpmDiffSkeleton; diff --git a/src/app/[...parts]/_page/NpmDiff/NpmDiff.tsx b/src/app/[...parts]/_page/NpmDiff/NpmDiff.tsx new file mode 100644 index 00000000..62abe809 --- /dev/null +++ b/src/app/[...parts]/_page/NpmDiff/NpmDiff.tsx @@ -0,0 +1,51 @@ +"use server"; + +import type { Options } from "libnpmdiff"; +import { Suspense } from "react"; +import type { FileData } from "react-diff-view"; +import { gitDiffParse } from "^/lib/gitDiff"; +import npmDiff from "^/lib/npmDiff"; +import SimplePackageSpec from "^/lib/SimplePackageSpec"; +import countChanges from "^/lib/utils/countChanges"; +import NoDiff from "./NoDiff"; +import NpmDiffClient from "./NpmDiff.client"; + +export interface NpmDiffProps { + a: SimplePackageSpec; + b: SimplePackageSpec; + specs: [string, string]; + options: Options; +} + +const NpmDiff = async ({ a, b, specs, options }: NpmDiffProps) => { + const diff = await npmDiff(specs, options); + + let files: FileData[] = gitDiffParse(diff); + + if (files.length === 0) { + return ; + } + + const changes = files.map((file) => countChanges(file.hunks)); + const additions = changes + .map(({ additions }) => additions) + .reduce((a, b) => a + b, 0); + const deletions = changes + .map(({ deletions }) => deletions) + .reduce((a, b) => a + b, 0); + + return ( + // Wrap in suspense because it uses dynamic function https://beta.nextjs.org/docs/rendering/static-and-dynamic-rendering#using-dynamic-functions + + + + ); +}; + +export default NpmDiff; diff --git a/src/app/[...parts]/_page/NpmDiff/ViewTypeSwitch.tsx b/src/app/[...parts]/_page/NpmDiff/ViewTypeSwitch.tsx new file mode 100644 index 00000000..34a5d09d --- /dev/null +++ b/src/app/[...parts]/_page/NpmDiff/ViewTypeSwitch.tsx @@ -0,0 +1,86 @@ +import { Link, LinkProps } from "@chakra-ui/next-js"; +import { + Button, + ButtonGroup, + ButtonGroupProps, + ButtonProps, + forwardRef, +} from "@chakra-ui/react"; +import { + ReadonlyURLSearchParams, + usePathname, + useSearchParams, +} from "next/navigation"; +import type { ViewType } from "react-diff-view"; +import useViewType from "^/lib/utils/useViewType"; +import { DIFF_TYPE_PARAM_NAME } from "../paramNames"; + +export interface ViewTypeButtonProps + extends Omit { + currentViewType: ViewType; + pathname: string | null; + searchParams: ReadonlyURLSearchParams | null; + viewType: ViewType; +} + +const ViewTypeButton = forwardRef( + ( + { + currentViewType, + pathname, + searchParams, + viewType, + children, + ...props + }, + ref, + ) => ( + + ), +); + +export interface ViewTypeSwitchProps extends ButtonGroupProps {} + +const ViewTypeSwitch = forwardRef( + (props, ref) => { + const buttonProps = { + currentViewType: useViewType(), + searchParams: useSearchParams(), + pathname: usePathname(), + }; + + return ( + + + Split + + + Unified + + + ); + }, +); + +export default ViewTypeSwitch; diff --git a/src/app/[...parts]/_page/PackagephobiaDiff.tsx b/src/app/[...parts]/_page/PackagephobiaDiff.tsx new file mode 100644 index 00000000..39ab917c --- /dev/null +++ b/src/app/[...parts]/_page/PackagephobiaDiff.tsx @@ -0,0 +1,82 @@ +import packagephobia from "^/lib/api/packagephobia"; +import TIMED_OUT from "^/lib/api/TimedOut"; +import { Packagephobia } from "^/lib/Services"; +import SimplePackageSpec from "^/lib/SimplePackageSpec"; +import measuredPromise from "^/lib/utils/measuredPromise"; +import SizeComparison, { SizeComparisonSkeleton } from "./SizeComparison"; + +export interface PackagephobiaDiffProps { + a: SimplePackageSpec; + b: SimplePackageSpec; + specs: [string, string]; +} + +const { name } = Packagephobia; + +const PackagephobiaDiff = async ({ specs, a, b }: PackagephobiaDiffProps) => { + const { result, time } = await measuredPromise(packagephobia(specs)); + + if (result == null) { + console.warn(`${name} result is null`, { specs }); + return null; + } + + if (result === TIMED_OUT) { + console.warn(`${name} timed out`, { specs }); + return null; + } + + console.log(name, { specs, time }); + + return ( + + ); +}; + +export default PackagephobiaDiff; + +export const PackagephobiaDiffSkeleton = () => ( + +); diff --git a/src/components/ServiceIcon.tsx b/src/app/[...parts]/_page/ServiceIcon.tsx similarity index 100% rename from src/components/ServiceIcon.tsx rename to src/app/[...parts]/_page/ServiceIcon.tsx diff --git a/src/app/[...parts]/_page/SizeComparison/SizeComparison.tsx b/src/app/[...parts]/_page/SizeComparison/SizeComparison.tsx new file mode 100644 index 00000000..82f9aade --- /dev/null +++ b/src/app/[...parts]/_page/SizeComparison/SizeComparison.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { Box, Flex, Heading, LinkProps, Text } from "@chakra-ui/react"; +import { FunctionComponent, ReactNode } from "react"; +import ExternalLink from "^/components/ExternalLink"; +import Span from "^/components/Span"; +import { Service, ServiceName, Services } from "^/lib/Services"; +import SimplePackageSpec from "^/lib/SimplePackageSpec"; +import { prettyByte } from "^/lib/utils/prettyByte"; +import Halfs from "../DiffIntro/Halfs"; +import ServiceTooltip from "../DiffIntro/ServiceTooltip"; + +function differance(a: number, b: number): ReactNode { + const diff = a - b; + + if (diff < 0) { + return ` (${diff})`; + } else if (diff > 0) { + return ` (+${diff})`; + } else { + return ""; + } +} + +function byteDifferance(a: number, b: number): ReactNode { + const diff = b - a; + + if (diff < 0) { + return ` (${prettyByte(diff)})`; + } else if (diff > 0) { + return ` (+${prettyByte(diff)})`; + } else { + return ""; + } +} + +const LinkButton: FunctionComponent< + LinkProps & { + pkg: SimplePackageSpec; + service: Service; + } +> = ({ pkg, service, ...props }) => { + return ( + + + + ); +}; + +const SizeText: FunctionComponent<{ + bytes: number; + color?: string; + num?: number; + baseBytes?: number; +}> = ({ bytes, color, baseBytes }) => ( + + {prettyByte(bytes)} + {baseBytes != null && baseBytes != 0 && ( + {byteDifferance(baseBytes, bytes)} + )} + +); + +const CountText: FunctionComponent<{ + count: number; + baseCount?: number; +}> = ({ count, baseCount }) => ( + + {count} + {baseCount != null && baseCount != 0 && ( + {differance(baseCount, count)} + )} + +); + +export interface Size { + bytes?: number; + count?: number; + color?: string; +} + +export interface SizeComparisonRow { + /** Like "install" or "gzipped" */ + name: string; + a: Size; + b: Size; +} + +export interface SizeComparisonProps { + serviceName: ServiceName; + flags?: ReactNode; + a: SimplePackageSpec; + b: SimplePackageSpec; + sizeRows: SizeComparisonRow[]; +} + +/** The padding of the center column and the right/left half has to be the same to line up */ +const COMMON_PADDING = "8px"; + +const SizeComparison = ({ + serviceName, + flags, + a, + b, + sizeRows, +}: SizeComparisonProps) => { + const service = Services[serviceName]; + return ( + <> + {service.name} + {flags} + + {sizeRows.map(({ name, a }) => { + if (a.bytes != null) { + return ( + + ); + } else if (a.count != null) { + return ; + } else { + return null; + } + })} + + } + center={ + + {sizeRows.map((sizeRow) => ( + {sizeRow.name} + ))} + + } + right={ + + + {sizeRows.map(({ name, a, b }) => { + if (b.bytes != null) { + return ( + + ); + } else if (b.count != null) { + return ( + + ); + } else { + return null; + } + })} + + + } + /> + + ); +}; + +export default SizeComparison; diff --git a/src/app/[...parts]/_page/SizeComparison/SizeComparisonSkeleton.tsx b/src/app/[...parts]/_page/SizeComparison/SizeComparisonSkeleton.tsx new file mode 100644 index 00000000..3be4383a --- /dev/null +++ b/src/app/[...parts]/_page/SizeComparison/SizeComparisonSkeleton.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Box, Flex, Heading, Skeleton, Text, VStack } from "@chakra-ui/react"; +import { ReactNode } from "react"; +import { ServiceName, Services } from "^/lib/Services"; +import Halfs from "../DiffIntro/Halfs"; + +export interface SkeletonSizeRow { + name: string; + a: number; + b: number; +} + +export interface SizeComparisonSkeletonProps { + serviceName: ServiceName; + sizeRows: SkeletonSizeRow[]; + flags?: ReactNode; +} + +/** The padding of the center column and the right/left half has to be the same to line up */ +const COMMON_PADDING = "8px"; + +const SizeComparisonSkeleton = ({ + serviceName, + sizeRows, + flags, +}: SizeComparisonSkeletonProps) => { + const service = Services[serviceName]; + + return ( + <> + {service.name} + {flags} + + {sizeRows.map(({ name, a }) => ( + + ))} + + } + center={ + + {sizeRows.map(({ name }) => ( + {name} + ))} + + } + right={ + + + {sizeRows.map(({ name, b }) => ( + + ))} + + + } + > + + ); +}; + +export default SizeComparisonSkeleton; diff --git a/src/app/[...parts]/_page/SizeComparison/index.ts b/src/app/[...parts]/_page/SizeComparison/index.ts new file mode 100644 index 00000000..e19d561e --- /dev/null +++ b/src/app/[...parts]/_page/SizeComparison/index.ts @@ -0,0 +1,17 @@ +import SizeComparison, { + type Size, + type SizeComparisonProps, + type SizeComparisonRow, +} from "./SizeComparison"; +import SizeComparisonSkeleton, { + type SizeComparisonSkeletonProps, +} from "./SizeComparisonSkeleton"; + +export default SizeComparison; +export { + SizeComparisonSkeleton, + type Size, + type SizeComparisonProps, + type SizeComparisonRow, + type SizeComparisonSkeletonProps, +}; diff --git a/src/app/[...parts]/_page/paramNames.ts b/src/app/[...parts]/_page/paramNames.ts new file mode 100644 index 00000000..2a5eb2bc --- /dev/null +++ b/src/app/[...parts]/_page/paramNames.ts @@ -0,0 +1 @@ +export const DIFF_TYPE_PARAM_NAME = "diff"; diff --git a/src/app/[...parts]/error.tsx b/src/app/[...parts]/error.tsx new file mode 100644 index 00000000..3ab61c74 --- /dev/null +++ b/src/app/[...parts]/error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Button, Center, Code } from "@chakra-ui/react"; +import { useEffect } from "react"; +import type { ErrorComponent } from "^/next"; +import ErrorBox from "./_error/ErrorBox"; + +const DiffError: ErrorComponent = ({ error: error, reset }) => { + const message = error?.message ?? error ?? "Unknown error"; + + useEffect(() => { + // Log the error to an error reporting service + console.error(message); + }, [message]); + + return ( +
+ +

Something Went Wrong

+ {`${message}`} + +
+
+ ); +}; + +export default DiffError; diff --git a/src/app/[...parts]/page.tsx b/src/app/[...parts]/page.tsx new file mode 100644 index 00000000..e815a394 --- /dev/null +++ b/src/app/[...parts]/page.tsx @@ -0,0 +1,116 @@ +import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import { ViewType } from "react-diff-view"; +import { DEFAULT_DIFF_FILES_GLOB } from "^/lib/default-diff-files"; +import destination from "^/lib/destination"; +import { parseQuery, QueryParams } from "^/lib/query"; +import { createSimplePackageSpec } from "^/lib/SimplePackageSpec"; +import decodeParts from "^/lib/utils/decodeParts"; +import specsToDiff from "^/lib/utils/specsToDiff"; +import splitParts from "^/lib/utils/splitParts"; +import BundlephobiaDiff, { + BundlephobiaDiffSkeleton, +} from "./_page/BundlephobiaDiff"; +import DiffIntro from "./_page/DiffIntro"; +import NpmDiff from "./_page/NpmDiff/NpmDiff"; +import NpmDiffSkeleton from "./_page/NpmDiff/NpmDiff.skeleton"; +import PackagephobiaDiff, { + PackagephobiaDiffSkeleton, +} from "./_page/PackagephobiaDiff"; +import { DIFF_TYPE_PARAM_NAME } from "./_page/paramNames"; + +export interface DiffPageProps { + params: { parts: string | string[] }; + searchParams: QueryParams & { [DIFF_TYPE_PARAM_NAME]: ViewType }; +} + +export function generateMetadata({ params: { parts } }: DiffPageProps) { + const specs = splitParts(decodeParts(parts)); + + const [a, b] = specs.map((spec) => createSimplePackageSpec(spec)); + + return { + title: `Comparing ${a.name}@${a.version}...${b.name}@${b.version}`, + description: `A diff between the npm packages "${a.name}@${a.version}" and "${b.name}@${b.version}"`, + }; +} + +// So while it would be super cool to have a dynamic page with cached data to +// have the fancy Suspense loading, there's no data caching for third party +// data, only for `fetch()` calls. So if we don't want to redo the diff for +// every page load, we need to have a static page. +// https://beta.nextjs.org/docs/data-fetching/fetching#segment-cache-configuration +export const dynamic = "force-static"; + +const DiffPage = async ({ + params: { parts }, + searchParams, +}: DiffPageProps): Promise => { + const { diffFiles, ...optionsQuery } = searchParams; + + const specsOrVersions = splitParts(decodeParts(parts)); + const { redirect: redirectTarget, canonicalSpecs } = await destination( + specsOrVersions, + ); + + if (redirectTarget !== false) { + redirect( + `/${specsToDiff(canonicalSpecs)}?${Object.entries(searchParams) + .map(([key, value]) => `${key}=${value}`) + .join("&")}`, + ); + } else { + const options = parseQuery({ + // If no diffFiles is passed, use the default. + // This is done here, since we don't want a fall back in the API + diffFiles: diffFiles ?? DEFAULT_DIFF_FILES_GLOB, + ...optionsQuery, + }); + + const [a, b] = canonicalSpecs.map((spec) => + createSimplePackageSpec(spec), + ); + + return ( + Loading...}> + + }> + {/* @ts-expect-error Server Component */} + + + }> + {/* @ts-expect-error Server Component */} + + + + } + options={options} + /> + }> + {/* @ts-expect-error Server Component */} + + + + ); + } +}; + +export default DiffPage; diff --git a/src/components/Footer.tsx b/src/app/_layout/Footer.tsx similarity index 100% rename from src/components/Footer.tsx rename to src/app/_layout/Footer.tsx diff --git a/src/components/Header/ColorModeToggle.tsx b/src/app/_layout/Header/ColorModeToggle.tsx similarity index 95% rename from src/components/Header/ColorModeToggle.tsx rename to src/app/_layout/Header/ColorModeToggle.tsx index 8f355f7e..f4f2a4ca 100644 --- a/src/components/Header/ColorModeToggle.tsx +++ b/src/app/_layout/Header/ColorModeToggle.tsx @@ -5,7 +5,7 @@ import { IconButtonProps, useColorMode, } from "@chakra-ui/react"; -import { Tooltip } from "^/components/theme"; +import Tooltip from "^/components/Tooltip"; export interface ColorModeToggleProps extends Partial {} diff --git a/src/components/Header/GithubLink.tsx b/src/app/_layout/Header/GithubLink.tsx similarity index 88% rename from src/components/Header/GithubLink.tsx rename to src/app/_layout/Header/GithubLink.tsx index 6c5ce4ce..d93a5ed3 100644 --- a/src/components/Header/GithubLink.tsx +++ b/src/app/_layout/Header/GithubLink.tsx @@ -1,7 +1,8 @@ import { IconButton, IconButtonProps } from "@chakra-ui/react"; import { FunctionComponent } from "react"; import { DiGithubBadge } from "react-icons/di"; -import { ExternalLink, Tooltip } from "^/components/theme"; +import ExternalLink from "^/components/ExternalLink"; +import Tooltip from "^/components/Tooltip"; export interface GithubLinkProps extends Omit {} diff --git a/src/components/Header/Header.tsx b/src/app/_layout/Header/Header.tsx similarity index 96% rename from src/components/Header/Header.tsx rename to src/app/_layout/Header/Header.tsx index 28039d30..ee159ece 100644 --- a/src/components/Header/Header.tsx +++ b/src/app/_layout/Header/Header.tsx @@ -22,8 +22,8 @@ const Header: FunctionComponent = (props) => ( - = (props) => ( npm-diff.app 📦🔃 - +
about/ api diff --git a/src/components/Header/NavLink.tsx b/src/app/_layout/Header/NavLink.tsx similarity index 85% rename from src/components/Header/NavLink.tsx rename to src/app/_layout/Header/NavLink.tsx index d3b54322..35da7785 100644 --- a/src/components/Header/NavLink.tsx +++ b/src/app/_layout/Header/NavLink.tsx @@ -1,5 +1,6 @@ -import { Link, LinkProps } from "@chakra-ui/next-js"; -import { useRouter } from "next/router"; +import { Link } from "@chakra-ui/next-js"; +import { LinkProps } from "@chakra-ui/react"; +import { usePathname } from "next/navigation"; import { FunctionComponent, useEffect, useState } from "react"; export interface NavLinkProps extends LinkProps { @@ -11,7 +12,7 @@ const NavLink: FunctionComponent = ({ children, ...props }) => { - const { asPath } = useRouter(); + const asPath = usePathname(); const [isActive, setIsActive] = useState(false); useEffect(() => { diff --git a/src/components/Header/index.ts b/src/app/_layout/Header/index.ts similarity index 100% rename from src/components/Header/index.ts rename to src/app/_layout/Header/index.ts diff --git a/src/components/Landing/ExamplesList.tsx b/src/app/_page/ExamplesList.tsx similarity index 100% rename from src/components/Landing/ExamplesList.tsx rename to src/app/_page/ExamplesList.tsx diff --git a/src/components/Landing/Intro.tsx b/src/app/_page/Intro.tsx similarity index 91% rename from src/components/Landing/Intro.tsx rename to src/app/_page/Intro.tsx index e0b07ac0..b3c42567 100644 --- a/src/components/Landing/Intro.tsx +++ b/src/app/_page/Intro.tsx @@ -1,5 +1,5 @@ import { Box, BoxProps, Code, forwardRef, Text } from "@chakra-ui/react"; -import { ExternalLink } from "^/components/theme"; +import ExternalLink from "^/components/ExternalLink"; export interface IntroProps extends BoxProps {} diff --git a/src/components/Landing/MainForm/CenterInputAddon.tsx b/src/app/_page/MainForm/CenterInputAddon.tsx similarity index 100% rename from src/components/Landing/MainForm/CenterInputAddon.tsx rename to src/app/_page/MainForm/CenterInputAddon.tsx diff --git a/src/components/Landing/MainForm/MainForm.tsx b/src/app/_page/MainForm/MainForm.tsx similarity index 92% rename from src/components/Landing/MainForm/MainForm.tsx rename to src/app/_page/MainForm/MainForm.tsx index 8a32ccb8..394e3774 100644 --- a/src/components/Landing/MainForm/MainForm.tsx +++ b/src/app/_page/MainForm/MainForm.tsx @@ -8,7 +8,8 @@ import { } from "@chakra-ui/react"; import npa from "npm-package-arg"; import { FormEvent, useCallback, useMemo, useRef, useState } from "react"; -import { Tooltip } from "^/components/theme"; +import Tooltip from "^/components/Tooltip"; +import { AutocompleteSuggestion } from "^/lib/autocomplete"; import CenterInputAddon from "./CenterInputAddon"; import SpecInput from "./SpecInput"; @@ -19,11 +20,20 @@ export interface MainFormProps extends StackProps { overrideB: string | null; isLoading: boolean; handleSubmit: (a: string | undefined, b: string | undefined) => void; + fallbackSuggestions: AutocompleteSuggestion[]; } const MainForm = forwardRef( ( - { overrideA, overrideB, children, isLoading, handleSubmit, ...props }, + { + overrideA, + overrideB, + children, + isLoading, + handleSubmit, + fallbackSuggestions, + ...props + }, ref, ) => { const bRef = useRef(null); @@ -98,6 +108,7 @@ const MainForm = forwardRef( } : undefined), }} + fallbackSuggestions={fallbackSuggestions} > ... @@ -122,6 +133,7 @@ const MainForm = forwardRef( } : undefined), }} + fallbackSuggestions={fallbackSuggestions} > { inputProps?: Omit; inputRef?: RefObject; size: ComboboxBoxProps["size"]; + fallbackSuggestions?: AutocompleteSuggestion[]; } const SuggestionListText = ({ @@ -41,6 +42,7 @@ const SpecInput: FunctionComponent = ({ inputProps = {}, inputRef, size, + fallbackSuggestions = [], ...useNpmComboboxProps }) => { @@ -55,7 +57,7 @@ const SpecInput: FunctionComponent = ({ error, } = useNpmCombobox({ ...useNpmComboboxProps, - fallback: useContext(FallbackSuggestionsContext), + fallback: fallbackSuggestions, }); return ( diff --git a/src/components/Landing/MainForm/SpecInput/Suggestion/Suggestion.tsx b/src/app/_page/MainForm/SpecInput/Suggestion/Suggestion.tsx similarity index 100% rename from src/components/Landing/MainForm/SpecInput/Suggestion/Suggestion.tsx rename to src/app/_page/MainForm/SpecInput/Suggestion/Suggestion.tsx diff --git a/src/components/Landing/MainForm/SpecInput/Suggestion/Title.tsx b/src/app/_page/MainForm/SpecInput/Suggestion/Title.tsx similarity index 95% rename from src/components/Landing/MainForm/SpecInput/Suggestion/Title.tsx rename to src/app/_page/MainForm/SpecInput/Suggestion/Title.tsx index ed8141c5..6d7868ed 100644 --- a/src/components/Landing/MainForm/SpecInput/Suggestion/Title.tsx +++ b/src/app/_page/MainForm/SpecInput/Suggestion/Title.tsx @@ -1,6 +1,6 @@ import { Heading, HeadingProps } from "@chakra-ui/react"; import { FunctionComponent, memo } from "react"; -import Span from "^/components/theme/Span"; +import Span from "^/components/Span"; import emphasized from "./emphasized"; const Title: FunctionComponent< diff --git a/src/components/Landing/MainForm/SpecInput/Suggestion/VersionTag.tsx b/src/app/_page/MainForm/SpecInput/Suggestion/VersionTag.tsx similarity index 100% rename from src/components/Landing/MainForm/SpecInput/Suggestion/VersionTag.tsx rename to src/app/_page/MainForm/SpecInput/Suggestion/VersionTag.tsx diff --git a/src/components/Landing/MainForm/SpecInput/Suggestion/emphasized.tsx b/src/app/_page/MainForm/SpecInput/Suggestion/emphasized.tsx similarity index 100% rename from src/components/Landing/MainForm/SpecInput/Suggestion/emphasized.tsx rename to src/app/_page/MainForm/SpecInput/Suggestion/emphasized.tsx diff --git a/src/components/Landing/MainForm/SpecInput/Suggestion/index.ts b/src/app/_page/MainForm/SpecInput/Suggestion/index.ts similarity index 100% rename from src/components/Landing/MainForm/SpecInput/Suggestion/index.ts rename to src/app/_page/MainForm/SpecInput/Suggestion/index.ts diff --git a/src/components/Landing/MainForm/SpecInput/index.ts b/src/app/_page/MainForm/SpecInput/index.ts similarity index 100% rename from src/components/Landing/MainForm/SpecInput/index.ts rename to src/app/_page/MainForm/SpecInput/index.ts diff --git a/src/components/Landing/MainForm/index.ts b/src/app/_page/MainForm/index.ts similarity index 100% rename from src/components/Landing/MainForm/index.ts rename to src/app/_page/MainForm/index.ts diff --git a/src/components/Landing/OptionsForm.tsx b/src/app/_page/OptionsForm.tsx similarity index 94% rename from src/components/Landing/OptionsForm.tsx rename to src/app/_page/OptionsForm.tsx index 1418d695..d695e3cd 100644 --- a/src/components/Landing/OptionsForm.tsx +++ b/src/app/_page/OptionsForm.tsx @@ -6,7 +6,7 @@ import { Input, } from "@chakra-ui/react"; import { FunctionComponent } from "react"; -import ButtonExpandBox from "../theme/ButtonExpandBox"; +import ButtonExpandBox from "^/components/ButtonExpandBox"; export interface OptionsFormProps extends FlexProps { files: string; diff --git a/src/components/About/assets/external-services.darkmode.png b/src/app/about/_page/assets/external-services.darkmode.png similarity index 100% rename from src/components/About/assets/external-services.darkmode.png rename to src/app/about/_page/assets/external-services.darkmode.png diff --git a/src/components/About/assets/external-services.lightmode.png b/src/app/about/_page/assets/external-services.lightmode.png similarity index 100% rename from src/components/About/assets/external-services.lightmode.png rename to src/app/about/_page/assets/external-services.lightmode.png diff --git a/src/components/About/assets/index.ts b/src/app/about/_page/assets/index.ts similarity index 100% rename from src/components/About/assets/index.ts rename to src/app/about/_page/assets/index.ts diff --git a/src/app/about/api/page.client.tsx b/src/app/about/api/page.client.tsx new file mode 100644 index 00000000..22cd1ee0 --- /dev/null +++ b/src/app/about/api/page.client.tsx @@ -0,0 +1,59 @@ +"use client"; +import { Code, Heading, Text, VStack } from "@chakra-ui/react"; +import ExternalLink from "^/components/ExternalLink"; +import Tooltip from "^/components/Tooltip"; + +export interface AboutApiPageClientProps { + diff: string; + specs: [string, string]; + exampleAbsoluteUrl: string; +} + +const AboutApiPageClient = ({ + diff, + specs, + exampleAbsoluteUrl, +}: AboutApiPageClientProps) => ( + + + npm-diff.app API + + + npm-diff.app exposes a online API to equal{" "} + + npm diff + + , to be able to see the changes between versions of packages or + forks of packages. + + + + + GET{" "} + + + {exampleAbsoluteUrl} + + + +
+ will return the same as +
+ + npm diff --diff={specs[0]} --diff={specs[1]} + +
+ + a diff of the two provided packages + + + {diff} + +
+); + +export default AboutApiPageClient; diff --git a/src/app/about/api/page.tsx b/src/app/about/api/page.tsx new file mode 100644 index 00000000..b7c3ca7c --- /dev/null +++ b/src/app/about/api/page.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from "next"; +import destination from "^/lib/destination"; +import EXAMPLES from "^/lib/examples"; +import npmDiff from "^/lib/npmDiff"; +import splitParts from "^/lib/utils/splitParts"; +import AboutApiPageClient from "./page.client"; + +// TODO: Would be nice if this was dynamic. But doesn't seem possible +// https://github.com/vercel/next.js/discussions/12848 +const API_PATH = `/api` as const; +const EXAMPLE_QUERY = EXAMPLES[0]; +const EXAMPLE_RELATIVE_LINK = `${API_PATH}/${EXAMPLE_QUERY}` as const; + +const DOMAIN = "https://npm-diff.app"; +const EXAMPLE_ABSOLUTE_URL = `${DOMAIN}${EXAMPLE_RELATIVE_LINK}` as const; + +export const metadata = { + title: "API", + description: "API documentation for npm-diff.app", +} satisfies Metadata; + +// We need nodejs since we use Npm libs https://beta.nextjs.org/docs/api-reference/segment-config#runtime +export const runtime = "nodejs"; +const AboutApiPage = async () => { + const specsOrVersions = splitParts(EXAMPLE_QUERY); + const { canonicalSpecs } = await destination(specsOrVersions); + + const diff = await npmDiff(canonicalSpecs, {}); + + return ( + + ); +}; + +export default AboutApiPage; diff --git a/src/app/about/page.client.tsx b/src/app/about/page.client.tsx new file mode 100644 index 00000000..8a9ac47d --- /dev/null +++ b/src/app/about/page.client.tsx @@ -0,0 +1,88 @@ +"use client"; +import { + Code, + Heading, + Link, + Text, + useColorModeValue, + VStack, +} from "@chakra-ui/react"; +import NextImage from "next/image"; +import { + externalServicesDarkmode, + externalServicesLightmode, +} from "./_page/assets"; + +const AboutPageClient = () => { + const externalServicesImage = useColorModeValue( + externalServicesLightmode, + externalServicesDarkmode, + ); + + return ( + + + About npm-diff.app + + Inspect changes between npm packages in a webapp + + The comparing matches the behaviour of the CLI by using the + official{" "} + + libnpmdiff + {" "} + package to perform a diff between the packages you chose. We + then visualize the differance. + + + Redirection + + + For the URL you can use formats that are not exact, like:{" "} + + https://npm-diff.app/lodash@4.17.0...~4.17.15 + {" "} + or{" "} + + + https://npm-diff.app/react@18.0.0...react@latest + + + . If you go to any URL which is not exact, and therefore can + change over time, we will redirect you to the exact URL. You + will be redirected to an exact, canonical URL. This means you + will get a cached version even if you used a unique query. + + + External services + + + + We also use external services{" "} + bundlephobia and{" "} + packagephobia to + give an overview of the differances between the two, measured in + install and bundle size. + + + ); +}; + +export default AboutPageClient; diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx new file mode 100644 index 00000000..83283554 --- /dev/null +++ b/src/app/about/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import AboutPageClient from "./page.client"; + +export const metadata = { + title: "About", +} satisfies Metadata; + +// TODO export const runtime = "experimental-edge"; +const AboutPage = () => ; + +export default AboutPage; diff --git a/src/app/api/-/README.md b/src/app/api/-/README.md new file mode 100644 index 00000000..9a337c75 --- /dev/null +++ b/src/app/api/-/README.md @@ -0,0 +1,11 @@ +# The `-` prefix + +Our main endpoint `[...parts]` is a catch-all, but we still want to have other +endpoints, we nest our other endpoints under a `-` prefix. + +- `/api/example-package` is a valid request +- `/api/versions` could try to match `versions` as a package name + +So instead + +- `/api/-/versions` is a valid request diff --git a/src/pages/api/versions.ts b/src/app/api/-/versions/route.ts similarity index 77% rename from src/pages/api/versions.ts rename to src/app/api/-/versions/route.ts index 3700b154..2dab1a08 100644 --- a/src/pages/api/versions.ts +++ b/src/app/api/-/versions/route.ts @@ -1,18 +1,13 @@ -import { type NextRequest } from "next/server"; +import { NextResponse } from "next/server"; import packument from "^/lib/api/npm/packument"; +import { VERSIONS_PARAMETER_PACKAGE } from "./types"; -export const VERSIONS_PARAMETER_PACKAGE = "package"; -export type Version = { version: string; tags?: string[] }; -export type SpecsEndpointResponse = Version[]; +export const runtime = "edge"; -export const config = { - runtime: "edge", -}; - -export default async function versions(req: NextRequest) { +export async function GET(request: Request) { const start = Date.now(); - const { searchParams } = new URL(req.url); + const { searchParams } = new URL(request.url); const spec = searchParams.get(VERSIONS_PARAMETER_PACKAGE); if (spec == null) { @@ -23,7 +18,7 @@ export default async function versions(req: NextRequest) { return new Response("spec must be a string", { status: 400 }); } - const result = await packument(spec); + const result = await packument(spec, { next: { revalidate: 0 } }); const tags = result["dist-tags"]; /** @@ -50,7 +45,7 @@ export default async function versions(req: NextRequest) { tags: versionToTags[version], })); - return new Response(JSON.stringify(versions), { + return NextResponse.json(versions, { status: 200, headers: { "Content-Type": "application/json", diff --git a/src/app/api/-/versions/types.ts b/src/app/api/-/versions/types.ts new file mode 100644 index 00000000..6cfc9c16 --- /dev/null +++ b/src/app/api/-/versions/types.ts @@ -0,0 +1,3 @@ +export const VERSIONS_PARAMETER_PACKAGE = "package"; +export type Version = { version: string; tags?: string[] }; +export type SpecsEndpointResponse = Version[]; diff --git a/src/app/api/[...parts]/route.ts b/src/app/api/[...parts]/route.ts new file mode 100644 index 00000000..4324ad0c --- /dev/null +++ b/src/app/api/[...parts]/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import destination from "^/lib/destination"; +import npmDiff, { NpmDiffError } from "^/lib/npmDiff"; +import { parseQuery } from "^/lib/query"; +import { defaultPageCachingHeaders } from "^/lib/utils/headers"; +import specsToDiff from "^/lib/utils/specsToDiff"; +import splitParts from "^/lib/utils/splitParts"; + +enum STATUS_CODES { + TEMPORARY_REDIRECT = 307, + PERMANENT_REDIRECT = 308, +} + +export async function GET( + req: NextRequest, + { params: { parts } }: { params: { parts: string[] } }, +) { + const { searchParams } = new URL(req.url); + const options = Object.fromEntries(searchParams); + + const specsOrVersions = splitParts(parts); + + const { redirect: red, canonicalSpecs } = await destination( + specsOrVersions, + ); + + if (red === false) { + try { + const diff = await npmDiff(canonicalSpecs, parseQuery(options)); + + return new NextResponse(diff, { + status: 200, + headers: defaultPageCachingHeaders, + }); + } catch (e) { + const { code, error } = e as NpmDiffError; + + return NextResponse.json(error, { status: code }); + } + } else { + const newUrl = new URL(`/api/${specsToDiff(canonicalSpecs)}`, req.url); + + Array.from(searchParams).forEach(([key, value]) => { + newUrl.searchParams.set(key, value); + }); + + return NextResponse.redirect( + newUrl, + red === "permanent" + ? STATUS_CODES.PERMANENT_REDIRECT + : STATUS_CODES.TEMPORARY_REDIRECT, + ); + } +} diff --git a/public/apple-touch-icon.png b/src/app/apple-icon.png similarity index 100% rename from public/apple-touch-icon.png rename to src/app/apple-icon.png diff --git a/public/favicon.ico b/src/app/favicon.ico similarity index 100% rename from public/favicon.ico rename to src/app/favicon.ico diff --git a/src/app/icon.png b/src/app/icon.png new file mode 100644 index 00000000..9189190d Binary files /dev/null and b/src/app/icon.png differ diff --git a/src/app/layout.client.tsx b/src/app/layout.client.tsx new file mode 100644 index 00000000..94c33367 --- /dev/null +++ b/src/app/layout.client.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { CacheProvider } from "@chakra-ui/next-js"; +import { ChakraProvider, ColorModeScript, Stack } from "@chakra-ui/react"; +import { Analytics } from "@vercel/analytics/react"; +import { PropsWithChildren } from "react"; +import Div100vh from "react-div-100vh"; +import theme from "^/theme"; +import Footer from "./_layout/Footer"; +import Header from "./_layout/Header"; + +const PADDING = "1em" as const; + +const LayoutClient = ({ children }: PropsWithChildren<{}>) => ( + <> + + + + +
+ {children} +