0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-18 02:21:47 -05:00

Shade updates (#22045)

ref
https://linear.app/ghost/issue/DES-1085/update-shade-to-be-used-in-activitypub

- Shade so far was just used in our playground (Post analytics). It
needed to be prepared so that it can be integrated in real projects like
ActivityPub. This means cleaning up everything related to it like
conventions, file structure, documentation etc.
This commit is contained in:
Peter Zimon 2025-01-23 08:22:04 +01:00 committed by GitHub
parent 568322c378
commit e41fc2c4d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 497 additions and 191 deletions

View file

@ -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<typeof BoilerPlate>;
export default meta;
type Story = StoryObj<typeof BoilerPlate>;
export const Default: Story = {
args: {
children: 'This is a boilerplate component. Use as a basis to create new components.'
}
};

View file

@ -1,15 +0,0 @@
import React from 'react';
interface BoilerPlateProps {
children?: React.ReactNode;
}
const BoilerPlate: React.FC<BoilerPlateProps> = ({children}) => {
return (
<>
{children}
</>
);
};
export default BoilerPlate;

View file

@ -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<typeof Avatar>;
export default meta;
type Story = StoryObj<typeof Avatar>;
export const Default: Story = {
args: {
children: <AvatarFallback>AG</AvatarFallback>
}
};
export const WithImage: Story = {
args: {
children: (
<>
<AvatarImage src="https://avatars.githubusercontent.com/u/2178663?s=200&v=4" />
<AvatarFallback>AG</AvatarFallback>
</>
)
}
};

View file

@ -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<typeof Badge>;
export default meta;
type Story = StoryObj<typeof Badge>;
export const Default: Story = {
args: {
children: 'Badge'
}
};

View file

@ -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<typeof ChartContainer>;
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 (
<>
<ChartContainer
className="mx-auto aspect-square h-[250px] min-h-[250px] w-full"
config={chartConfig}
>
<PieChart>
<ChartTooltip
content={<ChartTooltipContent hideLabel />}
cursor={false}
/>
<Pie
data={chartData}
dataKey="visitors"
innerRadius={60}
nameKey="browser"
strokeWidth={5}
>
<Label
content={({viewBox}) => {
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
return (
<text
dominantBaseline="middle"
textAnchor="middle"
x={viewBox.cx}
y={viewBox.cy}
>
<tspan
className="fill-foreground text-2xl font-semibold tracking-tight"
x={viewBox.cx}
y={viewBox.cy}
>
{totalVisitors.toLocaleString()}
</tspan>
<tspan
className="fill-muted-foreground"
x={viewBox.cx}
y={(viewBox.cy || 0) + 20}
>
Reactions
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
<div className='text-center'>
Visit <a className="underline" href="https://ui.shadcn.com/docs/components/chart" rel="noreferrer" target="_blank">ShadCN/UI Charts docs</a> for usage details.
</div>
</>
);
}
};

View file

@ -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<typeof Dialog>;
export default meta;
type Story = StoryObj<typeof Dialog>;
export const Default: Story = {
args: {
children: (
<>
<DialogTrigger className='cursor-pointer'><Button className='cursor-pointer'>Open</Button></DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
</DialogHeader>
</DialogContent>
</>
)
}
};

View file

@ -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: {

View file

@ -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<typeof Separator>;
export default meta;
type Story = StoryObj<typeof Separator>;
export const Default: Story = {
args: {}
};

View file

@ -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<typeof Table>;
export default meta;
type Story = StoryObj<typeof Table>;
export const Default: Story = {
args: {
children: (
<>
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">ABC-123</TableCell>
<TableCell>Paid</TableCell>
<TableCell>Card</TableCell>
<TableCell className="text-right">$2,500.00</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={3}>Total</TableCell>
<TableCell className="text-right">$2,500.00</TableCell>
</TableRow>
</TableFooter>
</>
)
}
};

View file

@ -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 => (
<TooltipProvider>
<Story />
</TooltipProvider>
)
],
argTypes: {
children: {
table: {
disable: true
}
}
}
} satisfies Meta<typeof Tooltip>;
export default meta;
type Story = StoryObj<typeof Tooltip>;
export const Default: Story = {
args: {
children: (
<>
<TooltipTrigger>Hover me</TooltipTrigger>
<TooltipContent>Tooltip content</TooltipContent>
</>
)
}
};

View file

@ -14,15 +14,17 @@ const TooltipContent = React.forwardRef<
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({className, sideOffset = 4, ...props}, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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',
className
)}
sideOffset={sideOffset}
{...props}
/>
<div className='shade'>
<TooltipPrimitive.Content
ref={ref}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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',
className
)}
sideOffset={sideOffset}
{...props}
/>
</div>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;

View file

@ -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
</div>

View file

@ -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.
<p className="excerpt">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.</p>
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 `<Tooltip>` 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.
</div>

View file

@ -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 = {

View file

@ -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.
<p className="excerpt">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.</p>
## UI component API's

View file

@ -1,4 +1,5 @@
import { Meta } from '@storybook/blocks';
import techStackImage from './assets/tech-stack.png';
<Meta title="Welcome" />
@ -8,9 +9,10 @@ import { Meta } from '@storybook/blocks';
<p className="excerpt">**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.</p>
## 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.
<img src={techStackImage} alt="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.
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View file

@ -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';

View file

@ -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<T extends unknown[]>(func: (...args: T) => void, wait: number, immediate: boolean = false): (...args: T) => void {
let timeoutId: ReturnType<typeof setTimeout> | 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();
};

View file

@ -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;

View file

@ -1,24 +0,0 @@
export function debounce<T extends unknown[]>(func: (...args: T) => void, wait: number, immediate: boolean = false): (...args: T) => void {
let timeoutId: ReturnType<typeof setTimeout> | 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);
}
};
}

View file

@ -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);
};

View file

@ -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();
};

View file

@ -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 () {

View file

@ -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" }]
}