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:
parent
2a985d4c6f
commit
4c2635670b
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
|
||||
|
||||
|
@ -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"
|
||||
|
2
ghost/signup-form/.env.development
Normal file
2
ghost/signup-form/.env.development
Normal file
@ -0,0 +1,2 @@
|
||||
# Override this in .env.development.local if needed
|
||||
VITE_SITE_URL=https://127.0.0.1:2368
|
42
ghost/signup-form/.eslintrc.cjs
Normal file
42
ghost/signup-form/.eslintrc.cjs
Normal 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'}]
|
||||
}
|
||||
};
|
27
ghost/signup-form/.storybook/main.tsx
Normal file
27
ghost/signup-form/.storybook/main.tsx
Normal 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;
|
31
ghost/signup-form/.storybook/preview.tsx
Normal file
31
ghost/signup-form/.storybook/preview.tsx
Normal 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;
|
2
ghost/signup-form/.yarnrc
Normal file
2
ghost/signup-form/.yarnrc
Normal file
@ -0,0 +1,2 @@
|
||||
version-tag-prefix "@tryghost/signup-form@"
|
||||
version-git-message "Released Signup Form v%s"
|
28
ghost/signup-form/README.md
Normal file
28
ghost/signup-form/README.md
Normal 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
|
10
ghost/signup-form/demo/demo.tsx
Normal file
10
ghost/signup-form/demo/demo.tsx
Normal 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');
|
64
ghost/signup-form/index.html
Normal file
64
ghost/signup-form/index.html
Normal 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>
|
85
ghost/signup-form/package.json
Normal file
85
ghost/signup-form/package.json
Normal 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"
|
||||
}
|
||||
}
|
8
ghost/signup-form/postcss.config.cjs
Normal file
8
ghost/signup-form/postcss.config.cjs
Normal file
@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
52
ghost/signup-form/src/App.tsx
Normal file
52
ghost/signup-form/src/App.tsx
Normal 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;
|
22
ghost/signup-form/src/AppContext.ts
Normal file
22
ghost/signup-form/src/AppContext.ts
Normal 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);
|
20
ghost/signup-form/src/components/ContentBox.tsx
Normal file
20
ghost/signup-form/src/components/ContentBox.tsx
Normal 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>
|
||||
);
|
||||
};
|
70
ghost/signup-form/src/components/Frame.tsx
Normal file
70
ghost/signup-form/src/components/Frame.tsx
Normal 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>
|
||||
);
|
||||
};
|
68
ghost/signup-form/src/components/IFrame.tsx
Normal file
68
ghost/signup-form/src/components/IFrame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
70
ghost/signup-form/src/components/pages/FormPage.tsx
Normal file
70
ghost/signup-form/src/components/pages/FormPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
21
ghost/signup-form/src/components/pages/SuccessPage.tsx
Normal file
21
ghost/signup-form/src/components/pages/SuccessPage.tsx
Normal 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>;
|
||||
};
|
64
ghost/signup-form/src/index.tsx
Normal file
64
ghost/signup-form/src/index.tsx
Normal 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();
|
24
ghost/signup-form/src/pages.tsx
Normal file
24
ghost/signup-form/src/pages.tsx
Normal 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;
|
32
ghost/signup-form/src/styles/demo.css
Normal file
32
ghost/signup-form/src/styles/demo.css
Normal 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;
|
||||
}
|
8
ghost/signup-form/src/styles/iframe.css
Normal file
8
ghost/signup-form/src/styles/iframe.css
Normal file
@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Disable scrolling inside iframe */
|
||||
body, html {
|
||||
overflow: hidden;
|
||||
}
|
41
ghost/signup-form/src/utils/api.tsx
Normal file
41
ghost/signup-form/src/utils/api.tsx
Normal 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>;
|
1
ghost/signup-form/src/utils/constants.tsx
Normal file
1
ghost/signup-form/src/utils/constants.tsx
Normal file
@ -0,0 +1 @@
|
||||
export const ROOT_DIV_CLASS = 'gh-signup-root';
|
5
ghost/signup-form/src/utils/helpers.tsx
Normal file
5
ghost/signup-form/src/utils/helpers.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import {SignupFormOptions} from '../AppContext';
|
||||
|
||||
export function isMinimal(options: SignupFormOptions): boolean {
|
||||
return !options.title;
|
||||
}
|
4
ghost/signup-form/src/utils/validator.tsx
Normal file
4
ghost/signup-form/src/utils/validator.tsx
Normal 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
1
ghost/signup-form/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
194
ghost/signup-form/tailwind.config.cjs
Normal file
194
ghost/signup-form/tailwind.config.cjs
Normal 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')],
|
||||
};
|
8
ghost/signup-form/test/hello.test.js
Normal file
8
ghost/signup-form/test/hello.test.js
Normal 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'));
|
||||
});
|
||||
});
|
24
ghost/signup-form/tsconfig.json
Normal file
24
ghost/signup-form/tsconfig.json
Normal 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" }]
|
||||
}
|
10
ghost/signup-form/tsconfig.node.json
Normal file
10
ghost/signup-form/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts", "package.json"]
|
||||
}
|
60
ghost/signup-form/vite.config.ts
Normal file
60
ghost/signup-form/vite.config.ts
Normal 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
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user