diff --git a/apps/shade/src/boilerplate.stories.tsx b/apps/shade/src/boilerplate.stories.tsx deleted file mode 100644 index 3706413944..0000000000 --- a/apps/shade/src/boilerplate.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react'; - -import BoilerPlate from './boilerplate'; - -const meta = { - title: 'Meta / Boilerplate', - component: BoilerPlate, - tags: ['autodocs'] -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - children: 'This is a boilerplate component. Use as a basis to create new components.' - } -}; diff --git a/apps/shade/src/boilerplate.tsx b/apps/shade/src/boilerplate.tsx deleted file mode 100644 index 6aa687cc9d..0000000000 --- a/apps/shade/src/boilerplate.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; - -interface BoilerPlateProps { - children?: React.ReactNode; -} - -const BoilerPlate: React.FC = ({children}) => { - return ( - <> - {children} - - ); -}; - -export default BoilerPlate; diff --git a/apps/shade/src/components/ui/avatar.stories.tsx b/apps/shade/src/components/ui/avatar.stories.tsx new file mode 100644 index 0000000000..9cacd9262b --- /dev/null +++ b/apps/shade/src/components/ui/avatar.stories.tsx @@ -0,0 +1,35 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Avatar, AvatarFallback, AvatarImage} from './avatar'; + +const meta = { + title: 'Components / Avatar', + component: Avatar, + tags: ['autodocs'], + argTypes: { + children: { + table: { + disable: true + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: AG + } +}; + +export const WithImage: Story = { + args: { + children: ( + <> + + AG + + ) + } +}; diff --git a/apps/shade/src/components/ui/badge.stories.tsx b/apps/shade/src/components/ui/badge.stories.tsx new file mode 100644 index 0000000000..65e1bf9b0e --- /dev/null +++ b/apps/shade/src/components/ui/badge.stories.tsx @@ -0,0 +1,17 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Badge} from './badge'; + +const meta = { + title: 'Components / Badge', + component: Badge, + tags: ['autodocs'] +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'Badge' + } +}; diff --git a/apps/shade/src/components/ui/chart.stories.tsx b/apps/shade/src/components/ui/chart.stories.tsx new file mode 100644 index 0000000000..d70fdaaa9c --- /dev/null +++ b/apps/shade/src/components/ui/chart.stories.tsx @@ -0,0 +1,102 @@ +import type {Meta} from '@storybook/react'; +import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from './chart'; +import React from 'react'; +import {Label, Pie, PieChart} from 'recharts'; + +const meta = { + title: 'Components / Charts', + component: ChartContainer, + tags: ['autodocs'], + argTypes: { + children: { + control: false + } + } +} satisfies Meta; + +export default meta; + +export const Default = { + render: function ChartStory() { + const chartData = React.useMemo(() => { + return [ + {browser: 'chrome', visitors: 98, fill: 'var(--color-chrome)'}, + {browser: 'safari', visitors: 17, fill: 'var(--color-safari)'} + ]; + }, []); + + const chartConfig = { + visitors: { + label: 'Reactions' + }, + chrome: { + label: 'More like this', + color: 'hsl(var(--chart-1))' + }, + safari: { + label: 'Less like this', + color: 'hsl(var(--chart-5))' + } + } satisfies ChartConfig; + + const totalVisitors = React.useMemo(() => { + return chartData.reduce((acc, curr) => acc + curr.visitors, 0); + }, [chartData]); + + return ( + <> + + + } + cursor={false} + /> + + + + +
+ Visit ShadCN/UI Charts docs for usage details. +
+ + ); + } +}; \ No newline at end of file diff --git a/apps/shade/src/components/ui/dialog.stories.tsx b/apps/shade/src/components/ui/dialog.stories.tsx new file mode 100644 index 0000000000..4a4727b8fc --- /dev/null +++ b/apps/shade/src/components/ui/dialog.stories.tsx @@ -0,0 +1,34 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle} from './dialog'; +import {Button} from './button'; + +const meta = { + title: 'Components / Dialog', + component: Dialog, + tags: ['autodocs'], + argTypes: { + children: { + table: { + disable: true + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( + <> + + + + Are you absolutely sure? + + + + ) + } +}; diff --git a/apps/shade/src/components/ui/icon.ts b/apps/shade/src/components/ui/icon.ts index 6d76addedd..c08f515610 100644 --- a/apps/shade/src/components/ui/icon.ts +++ b/apps/shade/src/components/ui/icon.ts @@ -1,7 +1,6 @@ import React from 'react'; import {cva, type VariantProps} from 'class-variance-authority'; -import {cn} from '@/lib/utils'; -import {kebabToPascalCase} from '@/utils/formatText'; +import {cn, kebabToPascalCase} from '@/lib/utils'; const iconVariants = cva('', { variants: { diff --git a/apps/shade/src/components/ui/separator.stories.tsx b/apps/shade/src/components/ui/separator.stories.tsx new file mode 100644 index 0000000000..5708b376f4 --- /dev/null +++ b/apps/shade/src/components/ui/separator.stories.tsx @@ -0,0 +1,15 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Separator} from './separator'; + +const meta = { + title: 'Components / Separator', + component: Separator, + tags: ['autodocs'] +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {} +}; diff --git a/apps/shade/src/components/ui/table.stories.tsx b/apps/shade/src/components/ui/table.stories.tsx new file mode 100644 index 0000000000..83915c8adf --- /dev/null +++ b/apps/shade/src/components/ui/table.stories.tsx @@ -0,0 +1,50 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Table, TableCaption, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell} from './table'; + +const meta = { + title: 'Components / Table', + component: Table, + tags: ['autodocs'], + argTypes: { + children: { + table: { + disable: true + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( + <> + A list of your recent invoices. + + + Invoice + Status + Method + Amount + + + + + ABC-123 + Paid + Card + $2,500.00 + + + + + Total + $2,500.00 + + + + ) + } +}; diff --git a/apps/shade/src/components/ui/tooltip.stories.tsx b/apps/shade/src/components/ui/tooltip.stories.tsx new file mode 100644 index 0000000000..ef9e0d3e5b --- /dev/null +++ b/apps/shade/src/components/ui/tooltip.stories.tsx @@ -0,0 +1,37 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Tooltip, TooltipTrigger, TooltipContent} from './tooltip'; +import {TooltipProvider} from '@radix-ui/react-tooltip'; + +const meta = { + title: 'Components / Tooltip', + component: Tooltip, + tags: ['autodocs'], + decorators: [ + Story => ( + + + + ) + ], + argTypes: { + children: { + table: { + disable: true + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( + <> + Hover me + Tooltip content + + ) + } +}; diff --git a/apps/shade/src/components/ui/tooltip.tsx b/apps/shade/src/components/ui/tooltip.tsx index 7bdc8a9edd..5ac524a159 100644 --- a/apps/shade/src/components/ui/tooltip.tsx +++ b/apps/shade/src/components/ui/tooltip.tsx @@ -14,15 +14,17 @@ const TooltipContent = React.forwardRef< React.ComponentPropsWithoutRef >(({className, sideOffset = 4, ...props}, ref) => ( - +
+ +
)); TooltipContent.displayName = TooltipPrimitive.Content.displayName; diff --git a/apps/shade/src/docs/Conventions.mdx b/apps/shade/src/docs/Conventions.mdx index b34dd603ef..0796dcd032 100644 --- a/apps/shade/src/docs/Conventions.mdx +++ b/apps/shade/src/docs/Conventions.mdx @@ -26,6 +26,26 @@ className={cn(buttonVariants({variant, size, className}))} ## TailwindCSS color naming -[TK] +For gray colors up until now we've used `grey`. ShadCN follows TailwindCSS naming conventions and uses `gray` when you install a component. We also decided to go with this mainly to avoid having to manually override color names and risking manual errors in the design system. + +## Filenames + +Our generic naming conventions is: + +- `PascalCase` for React component names +- `kebab-case` for non-React-components like utilities, hooks and so on +- `camelCase` for functions, objects, types etc. + +When you install a ShadCN component via the CLI it'll create files with kebab-case. This is _not_ following our standards. Unfortunately changing _only_ filename casing in MacOS and Github is a **massive** PITA so for now we're accepting this inconsistency and let ShadCN create its files as is. + +## File structure + +- `/src/components/` — Root directory for components + - `/src/components/ui/` — Directory for atomic UI components (foundational UI elements such as buttons, dropdowns etc.) + - `/src/components/layout` — Complex, globally reusable components (such as a page or header container etc.) +- `/src/hooks/` — Custom Reach hooks +- `/src/providers/` — Context providers +- `/src/ib/utils.ts` — Utilities +- `/src/docs/` — Shade documentation diff --git a/apps/shade/src/docs/CreatingComponents.mdx b/apps/shade/src/docs/CreatingComponents.mdx index 183fb83fc7..a852cdbf08 100644 --- a/apps/shade/src/docs/CreatingComponents.mdx +++ b/apps/shade/src/docs/CreatingComponents.mdx @@ -6,12 +6,18 @@ import { Meta } from '@storybook/blocks'; # Adding new components -ShadCN/UI is actually not a library, it's a starting point — this means that whenever you install a new ShadCN/UI component it adds a new React `tsx` file with the default implementation for the given UI component. Then you can customize the implementation by applying TW classes, creating new variants and so on. +

ShadCN/UI is not a library, it's a starting point — this means that whenever you install a new ShadCN/UI component it adds new React component(s) with the default implementation for the given UI component. Then you can customize the implementation by applying TW classes, creating new variants and so on.

As an example, let's see how to add a "Button" component. +--- + **Step 1:** Go to [ShadCN/UI component list](https://ui.shadcn.com/docs/components) and find **Button** +> ⚠️ Sometimes the npx command fails with a massive error. This usually happens if ShadCN tries to reinstall an already existing third party library or component (e.g. `radix-ui/dialog` or `lucide-react`). In these cases, find out which package causes the issue (usually it's the one indicated under the manual installation of the given component), remove it from Shade's `package.json` and retry adding the ShadCN component via the CLI command. + +--- + **Step 2:** Install the component In the `shade` folder follow the ShadCN/UI CLI install instructions: @@ -35,6 +41,8 @@ If you take a look at the source of this component you'll see that the whole but - You can't "update" ShadCN/UI components, you can only reinstall. This means that if you re-run the install command it overwrites the existing implementation including all your changes, so be careful. - You can change the implementation as you wish. You can add, remove or CSS classes, create new variants and add UI logic if you want. +--- + **Step 3:** Create stories for the new component In order to make the new component appear in Storybook, you need to create at least one story for it. You can use the Boilerplate story as a starting point: copy it as `[component].stories.tsx` to the same folder as your component: @@ -53,6 +61,8 @@ For a story, at a minimum you'll need to: - Update the imported component (for the button example: `import {Button} from './button'`), and update all references in the stories file - Change the meta data (most imporantly, `title: 'Components / Button'`, where "Components" will be the parent directory of Button in Storybook — this is a meta directory it doesn't have to exist in the file system) +--- + **Step 4:** (Optional) Add or remove component variants ShadCN/UI uses [Class Variance Authority](https://cva.style/docs) to manage variants for UI components. If you look at the Button component, you'll see an example implementation of this: @@ -83,6 +93,8 @@ export interface ButtonProps } ``` +--- + **Step 5:** Export the component In order to be able to import the new component in apps, you'll need to export it in the `index.ts` file of Shade, like this: @@ -93,6 +105,12 @@ export * from './components/ui/button'; That's it — you've just added a new component to Shade and made it ready to use in other React apps. +--- + +**Note:** ShadCN uses a few third party libraries. Since we export everything from Shade under the project `@tryghost/shade` there might be conflicts if a third party library uses similar component names as some other components in Shade. For example, ShadCN uses Recharts to display charts. Recharts has a `` component and Shade also has one. To overcome this issue we alias all third party exports (e.g. `export * as Recharts from "recharts"`). + +--- + ## Stories With the automatic variant method, most of the _style related_ documentation is handled automatically in Shade. New stories should be created for non-stylistic use cases which are imporant to be able check. For the Button component an example could be related to the contents of the button: text-only, icon-only and icon-text buttons should be separated into different stories. @@ -113,6 +131,10 @@ The `components.json` file contains the ShadCN/UI default configuration, so when ## Custom components -[TK] +It is of course possible to create our own components. Examine how ShadCN components are structured and follow their patterns: + +- Create a single file for each UI component +- Create [composable components](https://blog.tomaszgil.me/choosing-the-right-path-composable-vs-configurable-components-in-react) (multiple React components), _not_ configurable ones (lots of props). Create all corresponding React components in the same file (take a look at the Dropdown Menu implementation for an example). +- Export custom components the same way as you would with ShadCN ones. diff --git a/apps/shade/src/docs/Environment.mdx b/apps/shade/src/docs/Environment.mdx index 4a0551f526..1b826dac0a 100644 --- a/apps/shade/src/docs/Environment.mdx +++ b/apps/shade/src/docs/Environment.mdx @@ -13,7 +13,7 @@ In order to make the proper TailwindCSS classes appear in code completion, you n Then, verify your VSCode settings: -1. Open settings.json (Cmd+Shift+P, then "Preferences: Open Settings (JSON)") +1. Open `settings.json` (Cmd+Shift+P, then "Preferences: Open Settings (JSON)") 2. Add these settings: ``` @@ -26,7 +26,7 @@ Then, verify your VSCode settings: } ``` -Also, ensure your project has a postcss.config.js file: +Also, ensure your project has a `postcss.config.js` file: ``` module.exports = { diff --git a/apps/shade/src/docs/UsingComponents.mdx b/apps/shade/src/docs/UsingComponents.mdx index 66e48ef347..cfef2089a0 100644 --- a/apps/shade/src/docs/UsingComponents.mdx +++ b/apps/shade/src/docs/UsingComponents.mdx @@ -6,7 +6,7 @@ import { Meta } from '@storybook/blocks'; # Component usage -The simplest way to use a component is to select the variant in Storybook and copy + paste the code from it. All components have their `className` prop forwarded to their rendered HTML component, which allows you to override any default classes. +

The simplest way to use a component is to select the variant in Storybook and copy + paste the code from it. All components have their `className` prop forwarded to their rendered HTML component, which allows you to override any default classes.

## UI component API's diff --git a/apps/shade/src/docs/Welcome.mdx b/apps/shade/src/docs/Welcome.mdx index 95182399dd..c5598da491 100644 --- a/apps/shade/src/docs/Welcome.mdx +++ b/apps/shade/src/docs/Welcome.mdx @@ -1,4 +1,5 @@ import { Meta } from '@storybook/blocks'; +import techStackImage from './assets/tech-stack.png'; @@ -8,9 +9,10 @@ import { Meta } from '@storybook/blocks';

**Shade** is Ghost's design system for product design. It contains all the necessary components and their usage that help you design a great product experience.

-## Stack - Shade uses the RadixUI based open source UI library, [ShadCN/UI](https://ui.shadcn.com/) as its foundation which does the heavy lifting and gives a great starting point for any UI component. Additionally Shade is built using [TailwindCSS](https://www.tailwindcss.com). Before diving deep into working with Shade, please get familiar with both of these libraries. -Shade uses [Storybook](https://storybook.js.org/) to document components and best practices. +Technology Stack + +Shade uses [Storybook](https://storybook.js.org/) to test and document components and best practices. For every component used in production, there must be a Storybook entry with at least one story that showcases the component. + diff --git a/apps/shade/src/docs/assets/tech-stack.png b/apps/shade/src/docs/assets/tech-stack.png new file mode 100644 index 0000000000..7bf53082fd Binary files /dev/null and b/apps/shade/src/docs/assets/tech-stack.png differ diff --git a/apps/shade/src/hooks/useGlobalDirtyState.tsx b/apps/shade/src/hooks/use-global-dirty-state.tsx similarity index 100% rename from apps/shade/src/hooks/useGlobalDirtyState.tsx rename to apps/shade/src/hooks/use-global-dirty-state.tsx diff --git a/apps/shade/src/index.ts b/apps/shade/src/index.ts index dab7abfff2..f45cd4a989 100644 --- a/apps/shade/src/index.ts +++ b/apps/shade/src/index.ts @@ -35,13 +35,11 @@ export {ReactComponent as GoogleLogo} from './assets/images/google-logo.svg'; export {ReactComponent as TwitterLogo} from './assets/images/twitter-logo.svg'; export {ReactComponent as XLogo} from './assets/images/x-logo.svg'; -export {default as useGlobalDirtyState} from './hooks/useGlobalDirtyState'; +export {default as useGlobalDirtyState} from './hooks/use-global-dirty-state'; // Utils export * from '@/lib/utils'; -export {cn} from '@/lib/utils'; -export {debounce} from './utils/debounce'; -export {formatUrl} from './utils/formatUrl'; +export {cn, debounce, kebabToPascalCase, formatUrl} from '@/lib/utils'; export {default as ShadeApp} from './ShadeApp'; export type {ShadeAppProps} from './ShadeApp'; diff --git a/apps/shade/src/lib/utils.ts b/apps/shade/src/lib/utils.ts index 45b87de67c..d526eeed37 100644 --- a/apps/shade/src/lib/utils.ts +++ b/apps/shade/src/lib/utils.ts @@ -1,6 +1,142 @@ import {clsx, type ClassValue} from 'clsx'; +import isEmail from 'validator/es/lib/isEmail'; import {twMerge} from 'tailwind-merge'; +// Helper to merge Tailwind classes export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +// Helper to debounce a function +export function debounce(func: (...args: T) => void, wait: number, immediate: boolean = false): (...args: T) => void { + let timeoutId: ReturnType | null; + + return function (this: unknown, ...args: T): void { + const later = () => { + timeoutId = null; + if (!immediate) { + func.apply(this, args); + } + }; + + const callNow = immediate && !timeoutId; + + if (timeoutId) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(later, wait); + + if (callNow) { + func.apply(this, args); + } + }; +} + +// Helper to convert kebab-case to PascalCase with numbers +export const kebabToPascalCase = (str: string): string => { + const processed = str + .replace(/[-_]([a-z0-9])/gi, (_, char) => char.toUpperCase()); + return processed.charAt(0).toUpperCase() + processed.slice(1); +}; + +// Helper to format a URL +export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => { + if (nullable && !value) { + return {save: null, display: ''}; + } + + let url = value.trim(); + + if (!url) { + if (baseUrl) { + return {save: '/', display: baseUrl}; + } + return {save: '', display: ''}; + } + + // if we have an email address, add the mailto: + if (isEmail(url)) { + return {save: `mailto:${url}`, display: `mailto:${url}`}; + } + + const isAnchorLink = url.match(/^#/); + if (isAnchorLink) { + return {save: url, display: url}; + } + + const isProtocolRelative = url.match(/^(\/\/)/); + if (isProtocolRelative) { + return {save: url, display: url}; + } + + if (!baseUrl) { + // Absolute URL with no base URL + if (!url.startsWith('http')) { + url = `https://${url}`; + } + } + + // If it doesn't look like a URL, leave it as is rather than assuming it's a pathname etc + if (!url.match(/^[a-zA-Z0-9-]+:/) && !url.match(/^(\/|\?)/)) { + return {save: url, display: url}; + } + + let parsedUrl: URL; + + try { + parsedUrl = new URL(url, baseUrl); + } catch (e) { + return {save: url, display: url}; + } + + if (!baseUrl) { + return {save: parsedUrl.toString(), display: parsedUrl.toString()}; + } + const parsedBaseUrl = new URL(baseUrl); + + let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0; + + // if our path is only missing a trailing / mark it as relative + if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) { + isRelativeToBasePath = true; + } + + const isOnSameHost = parsedUrl.host === parsedBaseUrl.host; + + // if relative to baseUrl, remove the base url before sending to action + if (isOnSameHost && isRelativeToBasePath) { + url = url.replace(/^[a-zA-Z0-9-]+:/, ''); + url = url.replace(/^\/\//, ''); + url = url.replace(parsedBaseUrl.host, ''); + url = url.replace(parsedBaseUrl.pathname, ''); + + if (!url.match(/^\//)) { + url = `/${url}`; + } + } + + if (!url.match(/\/$/) && !url.match(/[.#?]/)) { + url = `${url}/`; + } + + // we update with the relative URL but then transform it back to absolute + // for the input value. This avoids problems where the underlying relative + // value hasn't changed even though the input value has + return {save: url, display: displayFromBase(url, baseUrl)}; +}; + +// Helper to display a URL from a base URL +const displayFromBase = (url: string, baseUrl: string) => { + // Ensure base url has a trailing slash + if (!baseUrl.endsWith('/')) { + baseUrl += '/'; + } + + // Remove leading slash from url + if (url.startsWith('/')) { + url = url.substring(1); + } + + return new URL(url, baseUrl).toString(); +}; diff --git a/apps/shade/src/providers/ShadeProvider.tsx b/apps/shade/src/providers/ShadeProvider.tsx index 3d50e0558c..49d03a416a 100644 --- a/apps/shade/src/providers/ShadeProvider.tsx +++ b/apps/shade/src/providers/ShadeProvider.tsx @@ -2,7 +2,7 @@ import NiceModal from '@ebay/nice-modal-react'; import React, {createContext, useContext, useState} from 'react'; import {Toaster} from 'react-hot-toast'; // import {FetchKoenigLexical} from '../global/form/HtmlEditor'; -import {GlobalDirtyStateProvider} from '../hooks/useGlobalDirtyState'; +import {GlobalDirtyStateProvider} from '../hooks/use-global-dirty-state'; interface ShadeContextType { isAnyTextFieldFocused: boolean; diff --git a/apps/shade/src/utils/debounce.ts b/apps/shade/src/utils/debounce.ts deleted file mode 100644 index add7c3d5f1..0000000000 --- a/apps/shade/src/utils/debounce.ts +++ /dev/null @@ -1,24 +0,0 @@ -export function debounce(func: (...args: T) => void, wait: number, immediate: boolean = false): (...args: T) => void { - let timeoutId: ReturnType | null; - - return function (this: unknown, ...args: T): void { - const later = () => { - timeoutId = null; - if (!immediate) { - func.apply(this, args); - } - }; - - const callNow = immediate && !timeoutId; - - if (timeoutId) { - clearTimeout(timeoutId); - } - - timeoutId = setTimeout(later, wait); - - if (callNow) { - func.apply(this, args); - } - }; -} diff --git a/apps/shade/src/utils/formatText.ts b/apps/shade/src/utils/formatText.ts deleted file mode 100644 index 9b761f9c41..0000000000 --- a/apps/shade/src/utils/formatText.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Helper to convert kebab-case to PascalCase with numbers -export const kebabToPascalCase = (str: string): string => { - const processed = str - .replace(/[-_]([a-z0-9])/gi, (_, char) => char.toUpperCase()); - return processed.charAt(0).toUpperCase() + processed.slice(1); -}; diff --git a/apps/shade/src/utils/formatUrl.ts b/apps/shade/src/utils/formatUrl.ts deleted file mode 100644 index b05852be6c..0000000000 --- a/apps/shade/src/utils/formatUrl.ts +++ /dev/null @@ -1,100 +0,0 @@ -import isEmail from 'validator/es/lib/isEmail'; - -export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => { - if (nullable && !value) { - return {save: null, display: ''}; - } - - let url = value.trim(); - - if (!url) { - if (baseUrl) { - return {save: '/', display: baseUrl}; - } - return {save: '', display: ''}; - } - - // if we have an email address, add the mailto: - if (isEmail(url)) { - return {save: `mailto:${url}`, display: `mailto:${url}`}; - } - - const isAnchorLink = url.match(/^#/); - if (isAnchorLink) { - return {save: url, display: url}; - } - - const isProtocolRelative = url.match(/^(\/\/)/); - if (isProtocolRelative) { - return {save: url, display: url}; - } - - if (!baseUrl) { - // Absolute URL with no base URL - if (!url.startsWith('http')) { - url = `https://${url}`; - } - } - - // If it doesn't look like a URL, leave it as is rather than assuming it's a pathname etc - if (!url.match(/^[a-zA-Z0-9-]+:/) && !url.match(/^(\/|\?)/)) { - return {save: url, display: url}; - } - - let parsedUrl: URL; - - try { - parsedUrl = new URL(url, baseUrl); - } catch (e) { - return {save: url, display: url}; - } - - if (!baseUrl) { - return {save: parsedUrl.toString(), display: parsedUrl.toString()}; - } - const parsedBaseUrl = new URL(baseUrl); - - let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0; - - // if our path is only missing a trailing / mark it as relative - if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) { - isRelativeToBasePath = true; - } - - const isOnSameHost = parsedUrl.host === parsedBaseUrl.host; - - // if relative to baseUrl, remove the base url before sending to action - if (isOnSameHost && isRelativeToBasePath) { - url = url.replace(/^[a-zA-Z0-9-]+:/, ''); - url = url.replace(/^\/\//, ''); - url = url.replace(parsedBaseUrl.host, ''); - url = url.replace(parsedBaseUrl.pathname, ''); - - if (!url.match(/^\//)) { - url = `/${url}`; - } - } - - if (!url.match(/\/$/) && !url.match(/[.#?]/)) { - url = `${url}/`; - } - - // we update with the relative URL but then transform it back to absolute - // for the input value. This avoids problems where the underlying relative - // value hasn't changed even though the input value has - return {save: url, display: displayFromBase(url, baseUrl)}; -}; - -const displayFromBase = (url: string, baseUrl: string) => { - // Ensure base url has a trailing slash - if (!baseUrl.endsWith('/')) { - baseUrl += '/'; - } - - // Remove leading slash from url - if (url.startsWith('/')) { - url = url.substring(1); - } - - return new URL(url, baseUrl).toString(); -}; diff --git a/apps/shade/test/unit/utils/formatUrl.test.ts b/apps/shade/test/unit/utils/formatUrl.test.ts index 2f61dd649b..e3c958ede3 100644 --- a/apps/shade/test/unit/utils/formatUrl.test.ts +++ b/apps/shade/test/unit/utils/formatUrl.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {formatUrl} from '../../../src/utils/formatUrl'; +import {formatUrl} from '@/lib/utils'; describe('formatUrl', function () { it('displays empty string if the input is empty and nullable is true', function () { diff --git a/apps/shade/tsconfig.json b/apps/shade/tsconfig.json index 8c59ec214b..5ed4b63be5 100644 --- a/apps/shade/tsconfig.json +++ b/apps/shade/tsconfig.json @@ -4,7 +4,7 @@ "lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "ESNext", "skipLibCheck": true, - "types": ["vite/client"], + "types": ["vite/client", "mocha"], /* Bundler mode */ "moduleResolution": "bundler", @@ -23,6 +23,6 @@ "@/*": ["./src/*"] } }, - "include": ["src"], + "include": ["src", "test"], "references": [{ "path": "./tsconfig.node.json" }] }