Added signup-form package (#16846)

fixes https://github.com/TryGhost/Team/issues/3275
fixes https://github.com/TryGhost/Team/issues/3279
fixes https://github.com/TryGhost/Team/issues/3278

This pull request adds a new signup form package to the Ghost core
repository. The signup form package is a React component, embeddable on
any site, that renders a form for users to subscribe to a Ghost site.
This commit is contained in:
Simon Backx 2023-05-23 14:58:33 +02:00 committed by GitHub
parent 2a985d4c6f
commit 4c2635670b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1106 additions and 0 deletions

4
.gitignore vendored
View File

@ -130,6 +130,10 @@ Caddyfile
/ghost/sodo-search/public/main.css
/ghost/sodo-search/umd
# Signup Form and local environments
/ghost/signup-form/umd
/ghost/signup-form/.env*.local
# Announcement-Bar
/ghost/announcement-bar/umd

View File

@ -200,6 +200,10 @@
"url": "https://cdn.jsdelivr.net/ghost/admin-x-settings@~{version}/dist/admin-x-settings.umd.js",
"version": "0.0"
},
"signupForm": {
"url": "https://cdn.jsdelivr.net/ghost/signup-form@~{version}/umd/signup-form.min.js",
"version": "0.0"
},
"tenor": {
"googleApiKey": null,
"contentFilter": "off"

View File

@ -0,0 +1,2 @@
# Override this in .env.development.local if needed
VITE_SITE_URL=https://127.0.0.1:2368

View File

@ -0,0 +1,42 @@
/* eslint-env node */
module.exports = {
root: true,
extends: [
'react-app',
'plugin:ghost/browser',
'plugin:react/recommended'
],
plugins: [
'ghost',
'tailwindcss'
],
rules: {
// sort multiple import lines into alphabetical groups
'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', {
memberSyntaxSortOrder: ['none', 'all', 'single', 'multiple']
}],
// suppress errors for missing 'import React' in JSX files, as we don't need it
'react/react-in-jsx-scope': 'off',
// ignore prop-types for now
'react/prop-types': 'off',
// custom react rules
'react/jsx-sort-props': ['error', {
reservedFirst: true,
callbacksLast: true,
shorthandLast: true,
locale: 'en'
}],
'react/button-has-type': 'error',
'react/no-array-index-key': 'error',
'tailwindcss/classnames-order': ['error', {config: 'tailwind.config.cjs'}],
'tailwindcss/enforces-negative-arbitrary-values': ['warn', {config: 'tailwind.config.cjs'}],
'tailwindcss/enforces-shorthand': ['warn', {config: 'tailwind.config.cjs'}],
'tailwindcss/migration-from-tailwind-2': ['warn', {config: 'tailwind.config.cjs'}],
'tailwindcss/no-arbitrary-value': 'off',
'tailwindcss/no-custom-classname': 'off',
'tailwindcss/no-contradicting-classname': ['error', {config: 'tailwind.config.cjs'}]
}
};

View File

@ -0,0 +1,27 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
{
name: '@storybook/addon-styling',
},
],
framework: {
name: "@storybook/react-vite",
options: {},
},
docs: {
autodocs: "tag",
},
// staticDirs: ['../public/fonts'],
async viteFinal(config, options) {
config.resolve.alias = {
crypto: require.resolve('rollup-plugin-node-builtins'),
}
return config;
},
};
export default config;

View File

@ -0,0 +1,31 @@
import React from 'react';
import '../src/styles/demo.css';
import type { Preview } from "@storybook/react";
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
options: {
storySort: {
order: ['Global', 'Settings', 'Experimental'],
},
},
},
decorators: [
(Story) => (
<div className="signup-form" style={{ padding: '24px' }}>
{/* 👇 Decorators in Storybook also accept a function. Replace <Story/> with Story() to enable it */}
<Story />
</div>
),
],
};
export default preview;

View File

@ -0,0 +1,2 @@
version-tag-prefix "@tryghost/signup-form@"
version-git-message "Released Signup Form v%s"

View File

@ -0,0 +1,28 @@
# Embeddable Signup Form
Embed a Ghost signup form on any site.
## Development
### Pre-requisites
- Run `yarn` in Ghost monorepo root
- Run `yarn` in this directory
### Running the development version
Run `yarn dev` to start the development server to test/develop the form standalone. This will generate a demo site from the `index.html` file which renders the app and makes it available on http://localhost:5137
## Develop
This is a monorepo package.
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests

View File

@ -0,0 +1,10 @@
console.log('Hello world!', import.meta);
// The demo is loaded via ESM, but normally the script is loaded via a <script> tag, using a UMD bundle.
// The script on itself expects document.currentScript to be set, but this is not the case when loaded via ESM.
// So we map it manually here
const scriptTag = document.querySelector('script');
document.currentScript = scriptTag;
import('../src/index.tsx');

View File

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Signup Form</title>
<link rel="stylesheet" href="/src/styles/demo.css" />
</head>
<body>
<div id="demo-container">
<h1>Full signup form</h1>
<p>
This is a live preview of the embeddable signup form<br>
It is currently connected to Ghost running at <code>%VITE_SITE_URL%</code>. Please duplicate <code>.env.development</code> as <code>.env.development.local</code> and modify it to change the site url locally (when you get an error when submitting the forms).
</p>
<!-- Because we need to use ESM modules during develoment, the src should be different to force reexecution of each script -->
<script
type="module"
src="/src/index.tsx"
data-title="My site name"
data-description="An independent publication about embeddable signup forms."
data-logo="https://user-images.githubusercontent.com/65487235/157884383-1b75feb1-45d8-4430-b636-3f7e06577347.png"
data-color="#4664dd"
data-site="%VITE_SITE_URL%"
data-labels="signup-form,with-logo"
></script>
<hr>
<h1>Without logo</h1>
<script
type="module"
src="/src/index.tsx?withoutlogo"
data-title="Site without logo"
data-description="An independent publication about embeddable signup forms."
data-color="#4664dd"
data-site="%VITE_SITE_URL%"
data-labels="signup-form,without-logo"
></script>
<hr>
<h1>Minimal</h1>
<script
type="module"
src="/src/index.tsx?other"
data-color="#ff0095"
data-site="%VITE_SITE_URL%"
data-labels="signup-form,minimal"
></script>
<hr>
<h1>With invalid configuration</h1>
<p>When you submit this one, it will throw an error.</p>
<script
type="module"
src="/src/index.tsx?other2"
data-color="#ff0095"
data-site="https://invalid/"
></script>
</div>
</body>
</html>

View File

@ -0,0 +1,85 @@
{
"name": "@tryghost/signup-form",
"version": "0.0.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/TryGhost/Ghost/tree/main/packages/signup-form"
},
"author": "Ghost Foundation",
"type": "module",
"files": [
"LICENSE",
"README.md",
"dist/"
],
"main": "./dist/signup-form.umd.cjs",
"module": "./dist/signup-form.js",
"exports": {
".": {
"import": "./dist/signup-form.js",
"require": "./dist/signup-form.umd.cjs"
}
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"dev": "vite",
"dev:preview": "concurrently \"vite preview\" \"vite build --watch\"",
"build": "tsc && vite build",
"lint": "yarn run lint:js",
"lint:js": "eslint --ext .js,.ts,.cjs,.tsx --cache src test",
"test:unit": "yarn build",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"preship": "yarn lint",
"ship": "STATUS=$(git status --porcelain); echo $STATUS; if [ -z \"$STATUS\" ]; then yarn version; fi",
"postship": "git push ${GHOST_UPSTREAM:-origin} --follow-tags && npm publish",
"prepublishOnly": "yarn build"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@tryghost/timezone-data": "0.3.0"
},
"devDependencies": {
"@storybook/addon-essentials": "7.0.12",
"@storybook/addon-interactions": "7.0.12",
"@storybook/addon-links": "7.0.12",
"@storybook/addon-styling": "1.0.6",
"@storybook/blocks": "7.0.12",
"@storybook/react": "7.0.12",
"@storybook/react-vite": "7.0.12",
"@storybook/testing-library": "0.1.0",
"@tailwindcss/forms": "0.5.3",
"@tailwindcss/line-clamp": "0.4.4",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@typescript-eslint/eslint-plugin": "5.57.1",
"@typescript-eslint/parser": "5.57.1",
"@vitejs/plugin-react": "4.0.0",
"autoprefixer": "10.4.14",
"concurrently": "8.0.1",
"eslint": "8.38.0",
"eslint-config-react-app": "7.0.1",
"eslint-plugin-ghost": "2.18.0",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-react-refresh": "0.3.4",
"eslint-plugin-tailwindcss": "3.11.0",
"postcss": "8.4.23",
"postcss-import": "^15.1.0",
"prop-types": "15.8.1",
"rollup-plugin-node-builtins": "2.1.2",
"storybook": "7.0.12",
"stylelint": "15.6.1",
"tailwindcss": "3.3.2",
"typescript": "5.0.4",
"vite": "4.3.8",
"vite-plugin-svgr": "3.2.0",
"vitest": "0.31.1"
}
}

View File

@ -0,0 +1,8 @@
module.exports = {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {}
}
};

View File

@ -0,0 +1,52 @@
import React, {ComponentProps} from 'react';
import pages, {Page, PageName} from './pages';
import {AppContext, SignupFormOptions} from './AppContext';
import {ContentBox} from './components/ContentBox';
import {Frame} from './components/Frame';
import {setupGhostApi} from './utils/api';
type Props = {
options: SignupFormOptions;
};
const App: React.FC<Props> = ({options}) => {
const [page, setPage] = React.useState<Page>({
name: 'FormPage',
data: {}
});
const api = React.useMemo(() => {
return setupGhostApi({siteUrl: options.site});
}, [options.site]);
const _setPage = <T extends PageName>(name: T, data: ComponentProps<typeof pages[T]>) => {
setPage({
name,
data
} as Page);
};
const context = {
page,
api,
options,
setPage: _setPage
};
const PageComponent = pages[page.name];
const data = page.data as any; // issue with TypeScript understanding the type here when passing it to the component
return (
<div>
<AppContext.Provider value={context}>
<Frame>
<ContentBox>
<PageComponent {...data} />
</ContentBox>
</Frame>
</AppContext.Provider>
</div>
);
};
export default App;

View File

@ -0,0 +1,22 @@
// Ref: https://reactjs.org/docs/context.html
import React, {ComponentProps} from 'react';
import pages, {Page, PageName} from './pages';
import {GhostApi} from './utils/api';
export type SignupFormOptions = {
title?: string,
description?: string,
logo?: string,
color?: string,
site: string,
labels: string[],
};
export type AppContextType = {
page: Page,
setPage: <T extends PageName>(name: T, data: ComponentProps<typeof pages[T]>) => void,
options: SignupFormOptions,
api: GhostApi,
}
export const AppContext = React.createContext<AppContextType>({} as any);

View File

@ -0,0 +1,20 @@
import React from 'react';
import {AppContext} from '../AppContext';
type Props = {
children: React.ReactNode
};
export const ContentBox: React.FC<Props> = ({children}) => {
const {color} = React.useContext(AppContext).options;
const style = {
'--gh-accent-color': color
} as React.CSSProperties;
return (
<section style={style}>
{children}
</section>
);
};

View File

@ -0,0 +1,70 @@
import IFrame from './IFrame';
import React, {useCallback, useState} from 'react';
import styles from '../styles/iframe.css?inline';
type FrameProps = {
children: React.ReactNode
};
/**
* This ResizableFrame takes the full width of the parent container
*/
export const Frame: React.FC<FrameProps> = ({children}) => {
const style: React.CSSProperties = {
width: '100%',
height: '0px' // = default height
};
return (
<ResizableFrame style={style} title="signup frame">
{children}
</ResizableFrame>
);
};
type ResizableFrameProps = FrameProps & {
style: React.CSSProperties,
title: string,
};
/**
* This TailwindFrame has the same height as it contents and mimics a shadow DOM component
*/
const ResizableFrame: React.FC<ResizableFrameProps> = ({children, style, title}) => {
const [iframeStyle, setIframeStyle] = useState(style);
const onResize = useCallback((iframeRoot: HTMLElement) => {
setIframeStyle((current) => {
return {
...current,
height: `${iframeRoot.scrollHeight}px`
};
});
}, []);
return (
<TailwindFrame style={iframeStyle} title={title} onResize={onResize}>
{children}
</TailwindFrame>
);
};
type TailwindFrameProps = ResizableFrameProps & {
onResize: (el: HTMLElement) => void
};
/**
* Loads all the CSS styles inside an iFrame.
*/
const TailwindFrame: React.FC<TailwindFrameProps> = ({children, onResize, style, title}) => {
const head = (
<>
<style dangerouslySetInnerHTML={{__html: styles}} />
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0" name="viewport" />
</>
);
return (
<IFrame head={head} style={style} title={title} onResize={onResize}>
{children}
</IFrame>
);
};

View File

@ -0,0 +1,68 @@
import {Component} from 'react';
import {createPortal} from 'react-dom';
/**
* This is still a class component because it causes issues with the behaviour (DOM recreation and layout glitches) if we switch to a functional component. Feel free to refactor.
*/
export default class IFrame extends Component<any> {
node: any;
iframeHtml: any;
iframeHead: any;
iframeRoot: any;
constructor(props: {onResize: (el: HTMLElement) => void, children: any}) {
super(props);
this.setNode = this.setNode.bind(this);
this.node = null;
}
componentDidMount() {
this.node.addEventListener('load', this.handleLoad);
}
handleLoad = () => {
this.setupFrameBaseStyle();
};
componentWillUnmount() {
this.node.removeEventListener('load', this.handleLoad);
}
setupFrameBaseStyle() {
if (this.node.contentDocument) {
this.iframeHtml = this.node.contentDocument.documentElement;
this.iframeHead = this.node.contentDocument.head;
this.iframeRoot = this.node.contentDocument.body;
this.forceUpdate();
if (this.props.onResize) {
(new ResizeObserver(_ => this.props.onResize(this.iframeRoot)))?.observe?.(this.iframeRoot);
}
// This is a bit hacky, but prevents us to need to attach even listeners to all the iframes we have
// because when we want to listen for keydown events, those are only send in the window of iframe that is focused
// To get around this, we pass down the keydown events to the main window
// No need to detach, because the iframe would get removed
this.node.contentWindow.addEventListener('keydown', (e: KeyboardEvent | undefined) => {
// dispatch a new event
window.dispatchEvent(
new KeyboardEvent('keydown', e)
);
});
}
}
setNode(node: any) {
this.node = node;
}
render() {
const {children, head, title = '', style = {}, onResize, ...rest} = this.props;
return (
<iframe srcDoc={`<!DOCTYPE html>`} {...rest} ref={this.setNode} frameBorder="0" style={style} title={title}>
{this.iframeHead && createPortal(head, this.iframeHead)}
{this.iframeRoot && createPortal(children, this.iframeRoot)}
</iframe>
);
}
}

View File

@ -0,0 +1,70 @@
import React, {FormEventHandler} from 'react';
import {AppContext} from '../../AppContext';
import {isMinimal} from '../../utils/helpers';
import {isValidEmail} from '../../utils/validator';
type Props = {};
export const FormPage: React.FC<Props> = () => {
const {options} = React.useContext(AppContext);
if (isMinimal(options)) {
return (
<Form />
);
}
const title = options.title;
const description = options.description;
const logo = options.logo;
return <div className='bg-grey-300 p-24'>
{logo && <img alt={title} src={logo} width='100' />}
{title && <h1 className="text-4xl font-bold">{title}</h1>}
{description && <p className='pb-3'>{description}</p>}
<Form />
</div>;
};
const Form: React.FC<Props> = () => {
const [email, setEmail] = React.useState('');
const [error, setError] = React.useState('');
const [loading, setLoading] = React.useState(false);
const {api, setPage, options} = React.useContext(AppContext);
const labels = options.labels;
const submit: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault();
if (!isValidEmail(email)) {
setError('Please enter a valid email address');
return;
}
setError('');
setLoading(true);
try {
await api.sendMagicLink({email, labels});
setPage('SuccessPage', {
email
});
} catch (_) {
setLoading(false);
setError('Something went wrong, please try again.');
}
};
const borderStyle = error ? 'border-red-500' : 'border-grey-500';
return (
<div>
<form className='flex' onSubmit={submit}>
<input className={'flex-1 p-3 border ' + borderStyle} disabled={loading} placeholder='jamie@example.com' type="text" value={email} onChange={e => setEmail(e.target.value)}/>
<button className='bg-accent p-3 text-white' disabled={loading} type='submit'>Subscribe</button>
</form>
{error && <p className='pt-0.5 text-red-500'>{error}</p>}
</div>
);
};

View File

@ -0,0 +1,21 @@
import React from 'react';
import {AppContext} from '../../AppContext';
import {isMinimal} from '../../utils/helpers';
type Props = {
email: string;
};
export const SuccessPage: React.FC<Props> = ({email}) => {
const {options} = React.useContext(AppContext);
if (isMinimal(options)) {
return <div>
<h1 className="text-xl font-bold">Now check your email!</h1>
</div>;
}
return <div className='bg-grey-300 p-24'>
<h1 className="text-4xl font-bold">Now check your email!</h1>
<p className='pb-3'>An email has been send to {email}.</p>
</div>;
};

View File

@ -0,0 +1,64 @@
import App from './App.tsx';
import React from 'react';
import ReactDOM from 'react-dom/client';
import {ROOT_DIV_CLASS} from './utils/constants';
import {SignupFormOptions} from './AppContext.ts';
function getScriptTag(): HTMLElement {
let scriptTag = document.currentScript as HTMLElement | null;
if (!scriptTag && import.meta.env.DEV) {
// In development mode, use any script tag (because in ESM mode, document.currentScript is not set)
// We use the first script in the body element
scriptTag = document.querySelector('body script:not([data-used="true"])') as HTMLElement;
if (scriptTag) {
scriptTag.dataset.used = 'true';
}
}
if (!scriptTag) {
throw new Error('[Signup Form] Cannot find current script tag');
}
return scriptTag;
}
/**
* Note that we need to support multiple signup forms on the same page, so we need to find the root div for each script tag
*/
function getRootDiv(scriptTag: HTMLElement) {
if (scriptTag.previousElementSibling && scriptTag.previousElementSibling.className === ROOT_DIV_CLASS) {
return scriptTag.previousElementSibling;
}
if (!scriptTag.parentElement) {
throw new Error('[Signup Form] Script tag does not have a parent element');
}
const elem = document.createElement('div');
elem.className = ROOT_DIV_CLASS;
scriptTag.parentElement.insertBefore(elem, scriptTag);
return elem;
}
function init() {
const scriptTag = getScriptTag();
const root = getRootDiv(scriptTag);
const options: SignupFormOptions = {
title: scriptTag.dataset.title || undefined,
description: scriptTag.dataset.description || undefined,
logo: scriptTag.dataset.logo || undefined,
color: scriptTag.dataset.color || undefined,
site: scriptTag.dataset.site || window.location.origin,
labels: scriptTag.dataset.labels ? scriptTag.dataset.labels.split(',') : []
};
ReactDOM.createRoot(root).render(
<React.StrictMode>
<App options={options} />
</React.StrictMode>
);
}
init();

View File

@ -0,0 +1,24 @@
import React from 'react';
import {FormPage} from './components/pages/FormPage';
import {SuccessPage} from './components/pages/SuccessPage';
// When adding a new page, also add it at the bottom to the Page type (*)
const Pages = {
FormPage,
SuccessPage
};
// (*) Note we have repeated names here, and don't use PageName
// to make type checking work for the Page type, so we have type checks in place
// that we pass the right data to the right page (otherwise it will only check if
// the name is correct, and the data is correct for any page, not the specific page)
export type Page = PageType<'FormPage'> | PageType<'SuccessPage'>;
export type PageName = keyof typeof Pages;
export type PageType<Name extends PageName> = {
name: Name;
// get props of component
data: React.ComponentProps<typeof Pages[Name]>;
}
export default Pages;

View File

@ -0,0 +1,32 @@
body {
font-family: sans-serif;
}
#demo-container {
max-width: 800px;
margin: 0 auto;
padding: 50px 0;
}
hr {
border: 0;
border-top: 1px solid #eee;
margin: 40px 0;
}
h1 {
padding: 20px 0;
margin: 0;
}
p {
padding: 0 0 40px 0;
margin: 0;
line-height: 2;
font-size: 16px;
}
code {
background: #eee;
padding: 2px 4px;
}

View File

@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Disable scrolling inside iframe */
body, html {
overflow: hidden;
}

View File

@ -0,0 +1,41 @@
export const setupGhostApi = ({siteUrl}: {siteUrl: string}) => {
const apiPath = 'members/api';
function endpointFor({type, resource}: {type: 'members', resource: string}) {
if (type === 'members') {
return `${siteUrl.replace(/\/$/, '')}/${apiPath}/${resource}/`;
}
throw new Error(`Unknown type ${type}`);
}
return {
sendMagicLink: async ({email, labels}: {email: string, labels: string[]}) => {
const url = endpointFor({type: 'members', resource: 'send-magic-link'});
const payload = JSON.stringify({
email,
emailType: 'signup',
labels
});
const response = await fetch(url, {
headers: {
'app-pragma': 'no-cache',
'x-ghost-version': '5.47',
'Content-Type': 'application/json'
},
body: payload,
method: 'POST'
});
if (response.status < 200 || response.status >= 300) {
return false;
}
return true;
}
};
};
export type GhostApi = ReturnType<typeof setupGhostApi>;

View File

@ -0,0 +1 @@
export const ROOT_DIV_CLASS = 'gh-signup-root';

View File

@ -0,0 +1,5 @@
import {SignupFormOptions} from '../AppContext';
export function isMinimal(options: SignupFormOptions): boolean {
return !options.title;
}

View File

@ -0,0 +1,4 @@
export const isValidEmail = (email: string) => {
const re = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
return !!email && re.test(String(email).toLowerCase());
};

1
ghost/signup-form/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,194 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
corePlugins: {
preflight: true
},
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
screens: {
sm: '480px',
md: '640px',
lg: '1024px',
xl: '1280px'
},
colors: {
accent: 'var(--gh-accent-color, #ff0095)',
transparent: 'transparent',
current: 'currentColor',
white: '#FFF',
black: '#15171A',
grey: {
DEFAULT: '#ABB4BE',
50: '#FAFAFB',
100: '#F4F5F6',
200: '#EBEEF0',
300: '#DDE1E5',
400: '#CED4D9',
500: '#AEB7C1',
600: '#95A1AD',
700: '#7C8B9A',
800: '#626D79',
900: '#394047'
},
green: {
DEFAULT: '#30CF43',
100: '#E1F9E4',
400: '#58DA67',
500: '#30CF43',
600: '#2AB23A'
},
blue: {
DEFAULT: '#14B8FF',
100: '#DBF4FF',
400: '#42C6FF',
500: '#14B8FF',
600: '#00A4EB'
},
purple: {
DEFAULT: '#8E42FF',
100: '#EDE0FF',
400: '#A366FF',
500: '#8E42FF',
600: '7B1FFF'
},
pink: {
DEFAULT: '#FB2D8D',
100: '#FFDFEE',
400: '#FF5CA8',
500: '#FB2D8D',
600: '#F70878'
},
red: {
DEFAULT: '#F50B23',
100: '#FFE0E0',
400: '#F9394C',
500: '#F50B23',
600: '#DC091E'
},
yellow: {
DEFAULT: '#FFB41F',
100: '#FFF1D6',
400: '#FFC247',
500: '#FFB41F',
600: '#F0A000'
},
lime: {
DEFAULT: '#B5FF18'
}
},
fontFamily: {
inter: 'Inter',
sans: 'Inter, -apple-system, BlinkMacSystemFont, avenir next, avenir, helvetica neue, helvetica, ubuntu, roboto, noto, segoe ui, arial, sans-serif',
serif: 'Georgia, serif',
mono: 'Consolas, Liberation Mono, Menlo, Courier, monospace'
},
boxShadow: {
DEFAULT: '0 0 1px rgba(0,0,0,.05), 0 5px 18px rgba(0,0,0,.08)',
sm: '0 0 1px rgba(0,0,0,.12), 0 1px 6px rgba(0,0,0,0.03), 0 6px 10px -8px rgba(0,0,0,.1)',
md: '0 0 1px rgba(0,0,0,.05), 0 8px 28px rgba(0,0,0,.12)',
lg: '0 0 7px rgba(0, 0, 0, 0.08), 0 2.1px 2.2px -5px rgba(0, 0, 0, 0.011), 0 5.1px 5.3px -5px rgba(0, 0, 0, 0.016), 0 9.5px 10px -5px rgba(0, 0, 0, 0.02), 0 17px 17.9px -5px rgba(0, 0, 0, 0.024), 0 31.8px 33.4px -5px rgba(0, 0, 0, 0.029), 0 76px 80px -5px rgba(0, 0, 0, 0.04)',
xl: '0 2.8px 2.2px rgba(0, 0, 0, 0.02), 0 6.7px 5.3px rgba(0, 0, 0, 0.028), 0 12.5px 10px rgba(0, 0, 0, 0.035), 0 22.3px 17.9px rgba(0, 0, 0, 0.042), 0 41.8px 33.4px rgba(0, 0, 0, 0.05), 0 100px 80px rgba(0, 0, 0, 0.07)',
inner: 'inset 0 0 4px 0 rgb(0 0 0 / 0.08)',
none: '0 0 #0000'
},
extend: {
spacing: {
px: '1px',
0: '0px',
0.5: '0.2rem',
1: '0.4rem',
1.5: '0.6rem',
2: '0.8rem',
2.5: '1rem',
3: '1.2rem',
3.5: '1.4rem',
4: '1.6rem',
5: '2rem',
6: '2.4rem',
7: '2.8rem',
8: '3.2rem',
9: '3.6rem',
10: '4rem',
11: '4.4rem',
12: '4.8rem',
14: '5.6rem',
16: '6.4rem',
18: '7.2rem',
20: '8rem',
24: '9.6rem',
28: '11.2rem',
32: '12.8rem',
36: '14.4rem',
40: '16rem',
44: '17.6rem',
48: '19.2rem',
52: '20.8rem',
56: '22.4rem',
60: '24rem',
64: '25.6rem',
72: '28.8rem',
80: '32rem',
96: '38.4rem'
},
maxWidth: {
none: 'none',
0: '0rem',
xs: '32rem',
sm: '38.4rem',
md: '44.8rem',
lg: '51.2rem',
xl: '57.6rem',
'2xl': '67.2rem',
'3xl': '76.8rem',
'4xl': '89.6rem',
'5xl': '102.4rem',
'6xl': '115.2rem',
'7xl': '128rem',
'8xl': '140rem',
'9xl': '156rem',
full: '100%',
min: 'min-content',
max: 'max-content',
fit: 'fit-content',
prose: '65ch'
},
borderRadius: {
sm: '0.3rem',
DEFAULT: '0.4rem',
md: '0.6rem',
lg: '0.8rem',
xl: '1.2rem',
'2xl': '1.6rem',
'3xl': '2.4rem',
full: '9999px'
},
fontSize: {
'2xs': '1.05rem',
base: '1.5rem',
xs: '1.2rem',
sm: '1.35rem',
md: '1.5rem',
lg: '1.8rem',
xl: '2rem',
'2xl': '2.4rem',
'3xl': '3rem',
'4xl': '3.6rem',
'5xl': ['4.2rem', '1.15'],
'6xl': ['6rem', '1'],
'7xl': ['7.2rem', '1'],
'8xl': ['9.6rem', '1'],
'9xl': ['12.8rem', '1']
},
lineHeight: {
base: '1.5em',
tight: '1.35em',
tighter: '1.25em',
supertight: '1.1em'
},
transition: {
basic: 'all 0.4 ease'
}
}
}
// plugins: [require('@tailwindcss/forms')],
};

View File

@ -0,0 +1,8 @@
const assert = require('assert');
describe('Hello world', function () {
it('Runs a test', function () {
// TODO: Write me!
assert.ok(require('../index'));
});
});

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "package.json"]
}

View File

@ -0,0 +1,60 @@
import pkg from './package.json';
import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr';
import {defineConfig} from 'vitest/config';
import {resolve} from 'path';
const outputFileName = pkg.name[0] === '@' ? pkg.name.slice(pkg.name.indexOf('/') + 1) : pkg.name;
// https://vitejs.dev/config/
export default (function viteConfig() {
return defineConfig({
plugins: [
svgr(),
react()
],
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.VITEST_SEGFAULT_RETRY': 3
},
preview: {
port: 6174
},
build: {
outDir: resolve(__dirname, 'umd'),
emptyOutDir: true,
minify: true,
sourcemap: true,
cssCodeSplit: true,
lib: {
entry: resolve(__dirname, 'src/index.tsx'),
formats: ['umd'],
name: pkg.name,
fileName(format) {
if (format === 'umd') {
return `${outputFileName}.min.js`;
}
return `${outputFileName}.js`;
}
},
rollupOptions: {
output: {}
},
commonjsOptions: {
include: [/packages/, /node_modules/]
}
},
test: {
globals: true, // required for @testing-library/jest-dom extensions
environment: 'jsdom',
setupFiles: './test/test-setup.js',
include: ['./test/unit/*'],
testTimeout: process.env.TIMEOUT ? parseInt(process.env.TIMEOUT) : 10000,
...(process.env.CI && { // https://github.com/vitest-dev/vitest/issues/1674
minThreads: 1,
maxThreads: 2
})
}
});
});