From 2efed7bfb113d5057a0bae44a19d3ad6721f0149 Mon Sep 17 00:00:00 2001 From: Bastien Sun <43788700+bastiensun@users.noreply.github.com> Date: Wed, 1 Nov 2023 21:30:08 +0100 Subject: [PATCH] feat: add json support --- index.html | 2 +- package-lock.json | 254 +++++++++++++++++++++++++++- package.json | 1 + src/app.test.tsx | 120 ++++++++++++- src/app.tsx | 2 + src/components/formatted-code.tsx | 14 +- src/components/language-select.tsx | 29 ++++ src/components/title.tsx | 19 ++- src/components/ui/select.tsx | 130 ++++++++++++++ src/components/unformatted-code.tsx | 11 +- src/lib/format-java.ts | 11 -- src/lib/format.ts | 37 ++++ src/lib/use-code.ts | 14 +- src/lib/use-language.ts | 65 +++++++ src/routes.tsx | 8 +- 15 files changed, 684 insertions(+), 33 deletions(-) create mode 100644 src/components/language-select.tsx create mode 100644 src/components/ui/select.tsx delete mode 100644 src/lib/format-java.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/use-language.ts diff --git a/index.html b/index.html index b7d06b2..ac5317a 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Prettier Java Playground + Prettier Playground
diff --git a/package-lock.json b/package-lock.json index 921dbc8..ac78870 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", @@ -2151,6 +2152,14 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@radix-ui/number": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", + "integrity": "sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", @@ -2242,6 +2251,23 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", + "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", @@ -2269,6 +2295,48 @@ } } }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", + "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", + "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", @@ -2412,6 +2480,49 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz", + "integrity": "sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", @@ -4099,6 +4210,17 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz", + "integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -5251,6 +5373,11 @@ "node": ">=6" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -7411,6 +7538,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/get-port": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.0.0.tgz", @@ -7951,6 +8086,14 @@ "node": ">= 0.4" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -10381,6 +10524,51 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", + "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.3", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz", + "integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==", + "dependencies": { + "react-style-singleton": "^2.2.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "6.17.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.17.0.tgz", @@ -10411,6 +10599,28 @@ "react-dom": ">=16.8" } }, + "node_modules/react-style-singleton": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", + "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", + "dependencies": { + "get-nonce": "^1.0.0", + "invariant": "^2.2.4", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -11968,8 +12178,7 @@ "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -12250,6 +12459,47 @@ "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==", "dev": true }, + "node_modules/use-callback-ref": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz", + "integrity": "sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", + "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", diff --git a/package.json b/package.json index 9b3adb7..bbcb97b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", diff --git a/src/app.test.tsx b/src/app.test.tsx index e0a3fbb..f8eed5e 100644 --- a/src/app.test.tsx +++ b/src/app.test.tsx @@ -2,7 +2,7 @@ import { render, renderWithRouter, screen } from "@/lib/test-utils"; import { routes } from "@/routes"; import { userEvent } from "@testing-library/user-event"; import { createMemoryRouter, RouterProvider } from "react-router-dom"; -import { describe, expect, it, test } from "vitest"; +import { describe, expect, it, test, vi } from "vitest"; test("happy path", async () => { // Arrange @@ -208,7 +208,7 @@ describe("data query string", () => { data: "undefined", }); const router = createMemoryRouter(routes, { - initialEntries: [`/?${urlSearchParameters.toString()}`], + initialEntries: [`/java?${urlSearchParameters.toString()}`], }); // Act @@ -275,3 +275,119 @@ describe("data query string", () => { ); }); }); + +test("language pathname", async () => { + // Arrange + const user = userEvent.setup(); + const router = createMemoryRouter(routes, { initialEntries: ["/"] }); + + // Act + render(); + + // Assert + expect(router.state.location.pathname).toEqual("/java"); + + const title = screen.getByRole("heading", { + name: /prettier java playground/iu, + }); + expect(title).toHaveTextContent(/prettier java playground/iu); + + const unformattedCodeTextarea = screen.getByRole("textbox"); + expect(unformattedCodeTextarea).toHaveTextContent( + 'class HelloWorld { public static void main( String[ ] args ) { System.out.println( "Hello, World!" ) ; } }', + ); + + // Arrange + window.HTMLElement.prototype.hasPointerCapture = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + + // Act + const languageSelect = screen.getByRole("combobox"); + await user.click(languageSelect); + + // Assert + expect( + screen.getByRole("option", { name: /java/iu, selected: true }), + ).toBeVisible(); + + // Act + await user.click( + screen.getByRole("option", { name: /json/iu, selected: false }), + ); + await user.click(languageSelect); + + // Assert + expect( + screen.getByRole("option", { name: /json/iu, selected: true }), + ).toBeVisible(); + expect(router.state.location.pathname).toEqual("/json"); + expect(title).toHaveTextContent(/prettier json playground/iu); + expect(unformattedCodeTextarea).toHaveTextContent('{ "hello" : "world"}'); + + // Act + await user.click( + screen.getByRole("option", { name: /java/iu, selected: false }), + ); + + // Assert + expect(unformattedCodeTextarea).toHaveTextContent( + 'class HelloWorld { public static void main( String[ ] args ) { System.out.println( "Hello, World!" ) ; } }', + ); +}); + +test.each(["/invalid-language", "/invalid-language/other-pathname"])( + "invalid pathname ('%s')", + async (invalidPathname) => { + // Arrange + const user = userEvent.setup(); + const router = createMemoryRouter(routes, { + initialEntries: [invalidPathname], + }); + + // Act + render(); + + // Assert + expect(router.state.location.pathname).toEqual("/java"); + + // Arrange + window.HTMLElement.prototype.hasPointerCapture = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + + // Act + await user.click(screen.getByRole("combobox")); + + // Assert + expect( + screen.getByRole("option", { name: /java/iu, selected: true }), + ).toBeVisible(); + }, +); + +test("json", async () => { + // Arrange + const user = userEvent.setup(); + const router = createMemoryRouter(routes, { + initialEntries: ["/json"], + }); + + window.HTMLElement.prototype.hasPointerCapture = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + + // Act + render(); + + const unformattedCodeTextarea = screen.getByRole("textbox"); + await user.clear(unformattedCodeTextarea); + await user.type( + unformattedCodeTextarea, + '{\\[}"Immortality","Heat Immunity","Inferno","Teleportation","Interdimensional travel"{\\]}', + ); + + // Assert + expect( + screen.getByRole("button", { + name: '[ "Immortality" , "Heat Immunity" , "Inferno" , "Teleportation" , "Interdimensional travel" ]', + }), + ).toBeVisible(); +}); diff --git a/src/app.tsx b/src/app.tsx index 6def541..ab238e0 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,4 +1,5 @@ import { CodeDisplay } from "@/components/code-display"; +import { LanguageSelect } from "@/components/language-select"; import { Title } from "@/components/title"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; @@ -14,6 +15,7 @@ export const App = (): JSX.Element => {
<div className="mt-12 flex items-center space-x-2"> + <LanguageSelect /> <Switch checked={isDiffView} id="airplane-mode" diff --git a/src/components/formatted-code.tsx b/src/components/formatted-code.tsx index 8cf9132..39094b3 100644 --- a/src/components/formatted-code.tsx +++ b/src/components/formatted-code.tsx @@ -3,9 +3,20 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { type Language, useLanguage } from "@/lib/use-language"; import { Highlight, themes } from "prism-react-renderer"; import { type JSX, useState } from "react"; +// eslint-disable-next-line consistent-return +const getHighlightLanguage = (language: Language): string => { + switch (language) { + case "java": + return "kotlin"; + case "json": + return "javascript"; + } +}; + type FormattedCodeProps = { readonly formattedCode: string; readonly setTooltipToCopied: () => void; @@ -17,6 +28,7 @@ export const FormattedCode = ({ setTooltipToCopied, tooltipMessage, }: FormattedCodeProps): JSX.Element => { + const [language] = useLanguage(); const [isTooltipOpen, setIsTooltipOpen] = useState(false); const handleClick = async (): Promise<void> => { @@ -40,7 +52,7 @@ export const FormattedCode = ({ > <Highlight code={formattedCode} - language="kotlin" + language={getHighlightLanguage(language)} theme={themes.oneLight} > {({ getLineProps, getTokenProps, style, tokens }): JSX.Element => ( diff --git a/src/components/language-select.tsx b/src/components/language-select.tsx new file mode 100644 index 0000000..4c6f572 --- /dev/null +++ b/src/components/language-select.tsx @@ -0,0 +1,29 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { SUPPORTED_LANGUAGES, useLanguage } from "@/lib/use-language"; +import { type JSX } from "react"; + +export const LanguageSelect = (): JSX.Element => { + const [language, setLanguage] = useLanguage(); + + return ( + <Select onValueChange={setLanguage} value={language}> + {/* eslint-disable-next-line react/forbid-component-props*/} + <SelectTrigger className="w-28"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {Object.entries(SUPPORTED_LANGUAGES).map(([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + ); +}; diff --git a/src/components/title.tsx b/src/components/title.tsx index 4bb0508..178ae41 100644 --- a/src/components/title.tsx +++ b/src/components/title.tsx @@ -1,8 +1,15 @@ +import { SUPPORTED_LANGUAGES, useLanguage } from "@/lib/use-language"; import { type JSX } from "react"; -export const Title = (): JSX.Element => ( - <h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl"> - ☕ Prettier Java{" "} - <span className="ml-1 font-semibold text-muted-foreground">Playground</span> - </h1> -); +export const Title = (): JSX.Element => { + const [language] = useLanguage(); + + return ( + <h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl"> + ☕ Prettier {SUPPORTED_LANGUAGES[language]}{" "} + <span className="ml-1 font-semibold text-muted-foreground"> + Playground + </span> + </h1> + ); +}; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..b08e713 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,130 @@ +import { cn } from "@/lib/utils"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown } from "lucide-react"; +import { + type ComponentPropsWithoutRef, + type ElementRef, + forwardRef, +} from "react"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = forwardRef< + ElementRef<typeof SelectPrimitive.Trigger>, + ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> +>(({ children, className, ...props }, ref) => ( + <SelectPrimitive.Trigger + // eslint-disable-next-line react/forbid-component-props + className={cn( + "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + ref={ref} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + {/* eslint-disable-next-line react/forbid-component-props */} + <ChevronDown className="h-4 w-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectContent = forwardRef< + ElementRef<typeof SelectPrimitive.Content>, + ComponentPropsWithoutRef<typeof SelectPrimitive.Content> +>(({ children, className, position = "popper", ...props }, ref) => ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + // eslint-disable-next-line react/forbid-component-props + className={cn( + "relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className, + )} + position={position} + ref={ref} + {...props} + > + <SelectPrimitive.Viewport + // eslint-disable-next-line react/forbid-component-props + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]", + )} + > + {children} + </SelectPrimitive.Viewport> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = forwardRef< + ElementRef<typeof SelectPrimitive.Label>, + ComponentPropsWithoutRef<typeof SelectPrimitive.Label> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Label + // eslint-disable-next-line react/forbid-component-props + className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} + ref={ref} + {...props} + /> +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = forwardRef< + ElementRef<typeof SelectPrimitive.Item>, + ComponentPropsWithoutRef<typeof SelectPrimitive.Item> +>(({ children, className, ...props }, ref) => ( + <SelectPrimitive.Item + // eslint-disable-next-line react/forbid-component-props + className={cn( + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + ref={ref} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + {/* eslint-disable-next-line react/forbid-component-props */} + <Check className="h-4 w-4" /> + </SelectPrimitive.ItemIndicator> + </span> + + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = forwardRef< + ElementRef<typeof SelectPrimitive.Separator>, + ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Separator + // eslint-disable-next-line react/forbid-component-props + className={cn("-mx-1 my-1 h-px bg-muted", className)} + ref={ref} + {...props} + /> +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/src/components/unformatted-code.tsx b/src/components/unformatted-code.tsx index fa415a9..a7fb888 100644 --- a/src/components/unformatted-code.tsx +++ b/src/components/unformatted-code.tsx @@ -1,6 +1,7 @@ import { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/components/ui/use-toast"; -import { formatJava } from "@/lib/format-java"; +import { format } from "@/lib/format"; +import { useLanguage } from "@/lib/use-language"; import { type ChangeEvent, type ClipboardEvent, type JSX } from "react"; type UnformattedCodeProps = { @@ -18,6 +19,7 @@ export const UnformattedCode = ({ setUnformattedCode, unformattedCode, }: UnformattedCodeProps): JSX.Element => { + const [language] = useLanguage(); const { toast } = useToast(); const handleChange = async ( @@ -32,9 +34,10 @@ export const UnformattedCode = ({ ): Promise<void> => { let formattedCode: string; try { - formattedCode = await formatJava( - event.clipboardData.getData("text/plain"), - ); + formattedCode = await format({ + code: event.clipboardData.getData("text/plain"), + language, + }); } catch { return; } diff --git a/src/lib/format-java.ts b/src/lib/format-java.ts deleted file mode 100644 index 9715631..0000000 --- a/src/lib/format-java.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { format } from "prettier"; -// @ts-expect-error Could not find a declaration file for module `prettier-plugin-java`. -import parserJava from "prettier-plugin-java"; - -export const formatJava = (code: string): Promise<string> => - format(code, { - parser: "java", - plugins: [parserJava], - printWidth: 120, - tabWidth: 4, - }); diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..a36faf5 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,37 @@ +import { type Language } from "@/lib/use-language"; +import { format as prettierFormat } from "prettier"; +import * as prettierPluginBabel from "prettier/plugins/babel"; +import * as prettierPluginEstree from "prettier/plugins/estree"; +// @ts-expect-error Could not find a declaration file for module `prettier-plugin-java`. +import parserJava from "prettier-plugin-java"; + +const formatJava = (code: string): Promise<string> => + prettierFormat(code, { + parser: "java", + plugins: [parserJava], + printWidth: 120, + tabWidth: 4, + }); + +const formatJson = (code: string): Promise<string> => + prettierFormat(code, { + parser: "json", + plugins: [prettierPluginBabel, prettierPluginEstree], + }); + +type FormatParameters = { + code: string; + language: Language; +}; + +export const format = ({ + code, + language, // eslint-disable-next-line consistent-return +}: FormatParameters): Promise<string> => { + switch (language) { + case "java": + return formatJava(code); + case "json": + return formatJson(code); + } +}; diff --git a/src/lib/use-code.ts b/src/lib/use-code.ts index 0ba9a6c..a1e9432 100644 --- a/src/lib/use-code.ts +++ b/src/lib/use-code.ts @@ -1,4 +1,5 @@ -import { formatJava } from "@/lib/format-java"; +import { format } from "@/lib/format"; +import { useLanguage } from "@/lib/use-language"; import { useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; @@ -27,8 +28,7 @@ const useUnformattedCode = ( } setUnformattedCode(decodedData); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [encodedData]); const synchronizeUnformattedCodeAndDataQueryString = ( updatedUnformattedCode: string, @@ -69,6 +69,7 @@ export const useCode = ( setUnformattedCode: (code: string) => void; unformattedCode: string; } => { + const [language] = useLanguage(); const [unformattedCode, setUnformattedCode] = useUnformattedCode(initialState); const [formattedCode, setFormattedCode] = useState(""); @@ -77,7 +78,10 @@ export const useCode = ( const runEffect = async (): Promise<void> => { let content; try { - content = await formatJava(unformattedCode); + content = await format({ + code: unformattedCode, + language, + }); } catch (error) { if (error instanceof Error) { setFormattedCode(error.message); @@ -90,7 +94,7 @@ export const useCode = ( }; runEffect(); - }, [unformattedCode]); + }, [language, unformattedCode]); return { formattedCode, diff --git a/src/lib/use-language.ts b/src/lib/use-language.ts new file mode 100644 index 0000000..5ca5128 --- /dev/null +++ b/src/lib/use-language.ts @@ -0,0 +1,65 @@ +import { useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +export const SUPPORTED_LANGUAGES = { java: "Java", json: "JSON" }; +export type Language = keyof typeof SUPPORTED_LANGUAGES; + +export const DEFAULT_LANGUAGE = "java"; + +const isLanguage = (language: string): language is Language => + language in SUPPORTED_LANGUAGES; + +// eslint-disable-next-line consistent-return +const getExampleFormattedCode = (language: Language): string => { + switch (language) { + case "java": + return `class HelloWorld +{ + public static void main( String[ ] args ) + { + System.out.println( "Hello, World!" ) ; + } +}`; + case "json": + return ` { + "hello" : "world"}`; + } +}; + +export const useLanguage = (): [Language, (language: Language) => void] => { + const navigate = useNavigate(); + const { language } = useParams(); + + useEffect(() => { + if (!language || !isLanguage(language)) { + navigate( + { + pathname: `/${DEFAULT_LANGUAGE}`, + search: `?${new URLSearchParams({ + data: btoa(getExampleFormattedCode(DEFAULT_LANGUAGE)), + })}`, + }, + { replace: true }, + ); + } + }, [language, navigate]); + + if (!language) { + throw new Error("`language` is always defined (cf. `routes`)"); + } + + const setLanguage = (selectedLanguage: Language): void => { + navigate({ + pathname: `/${selectedLanguage}`, + search: `?${new URLSearchParams({ + data: btoa(getExampleFormattedCode(selectedLanguage)), + })}`, + }); + }; + + if (!isLanguage(language)) { + return [DEFAULT_LANGUAGE, setLanguage]; + } + + return [language, setLanguage]; +}; diff --git a/src/routes.tsx b/src/routes.tsx index 53b35ce..a418f48 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,8 +1,14 @@ import { App } from "@/app"; +import { DEFAULT_LANGUAGE } from "@/lib/use-language"; +import { Navigate } from "react-router-dom"; export const routes = [ { element: <App />, - path: "/", + path: "/:language", + }, + { + element: <Navigate replace to={`/${DEFAULT_LANGUAGE}`} />, + path: "*", }, ];