diff --git a/.gitignore b/.gitignore index ea67295014..4887893825 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index 76d7cd139e..510b2311b5 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -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" diff --git a/ghost/signup-form/.env.development b/ghost/signup-form/.env.development new file mode 100644 index 0000000000..78483124be --- /dev/null +++ b/ghost/signup-form/.env.development @@ -0,0 +1,2 @@ +# Override this in .env.development.local if needed +VITE_SITE_URL=https://127.0.0.1:2368 diff --git a/ghost/signup-form/.eslintrc.cjs b/ghost/signup-form/.eslintrc.cjs new file mode 100644 index 0000000000..5e6629063e --- /dev/null +++ b/ghost/signup-form/.eslintrc.cjs @@ -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'}] + } +}; diff --git a/ghost/signup-form/.storybook/main.tsx b/ghost/signup-form/.storybook/main.tsx new file mode 100644 index 0000000000..23facc7c95 --- /dev/null +++ b/ghost/signup-form/.storybook/main.tsx @@ -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; diff --git a/ghost/signup-form/.storybook/preview.tsx b/ghost/signup-form/.storybook/preview.tsx new file mode 100644 index 0000000000..7540781ccd --- /dev/null +++ b/ghost/signup-form/.storybook/preview.tsx @@ -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) => ( +
+ {/* 👇 Decorators in Storybook also accept a function. Replace with Story() to enable it */} + +
+ ), + ], +}; + +export default preview; diff --git a/ghost/signup-form/.yarnrc b/ghost/signup-form/.yarnrc new file mode 100644 index 0000000000..6380a3bd68 --- /dev/null +++ b/ghost/signup-form/.yarnrc @@ -0,0 +1,2 @@ +version-tag-prefix "@tryghost/signup-form@" +version-git-message "Released Signup Form v%s" diff --git a/ghost/signup-form/README.md b/ghost/signup-form/README.md new file mode 100644 index 0000000000..3accb04260 --- /dev/null +++ b/ghost/signup-form/README.md @@ -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 diff --git a/ghost/signup-form/demo/demo.tsx b/ghost/signup-form/demo/demo.tsx new file mode 100644 index 0000000000..5f48cff1ad --- /dev/null +++ b/ghost/signup-form/demo/demo.tsx @@ -0,0 +1,10 @@ +console.log('Hello world!', import.meta); + +// The demo is loaded via ESM, but normally the script is loaded via a + +
+

Without logo

+ + +
+

Minimal

+ + + +
+

With invalid configuration

+

When you submit this one, it will throw an error.

+ + + + + diff --git a/ghost/signup-form/package.json b/ghost/signup-form/package.json new file mode 100644 index 0000000000..2d4e35242d --- /dev/null +++ b/ghost/signup-form/package.json @@ -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" + } + } diff --git a/ghost/signup-form/postcss.config.cjs b/ghost/signup-form/postcss.config.cjs new file mode 100644 index 0000000000..ab7c4939b1 --- /dev/null +++ b/ghost/signup-form/postcss.config.cjs @@ -0,0 +1,8 @@ +module.exports = { + plugins: { + 'postcss-import': {}, + 'tailwindcss/nesting': {}, + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/ghost/signup-form/src/App.tsx b/ghost/signup-form/src/App.tsx new file mode 100644 index 0000000000..36ccbc71cd --- /dev/null +++ b/ghost/signup-form/src/App.tsx @@ -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 = ({options}) => { + const [page, setPage] = React.useState({ + name: 'FormPage', + data: {} + }); + + const api = React.useMemo(() => { + return setupGhostApi({siteUrl: options.site}); + }, [options.site]); + + const _setPage = (name: T, data: ComponentProps) => { + 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 ( +
+ + + + + + + +
+ ); +}; + +export default App; diff --git a/ghost/signup-form/src/AppContext.ts b/ghost/signup-form/src/AppContext.ts new file mode 100644 index 0000000000..88ee8dd6b8 --- /dev/null +++ b/ghost/signup-form/src/AppContext.ts @@ -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: (name: T, data: ComponentProps) => void, + options: SignupFormOptions, + api: GhostApi, +} + +export const AppContext = React.createContext({} as any); diff --git a/ghost/signup-form/src/components/ContentBox.tsx b/ghost/signup-form/src/components/ContentBox.tsx new file mode 100644 index 0000000000..e148f3ce5f --- /dev/null +++ b/ghost/signup-form/src/components/ContentBox.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import {AppContext} from '../AppContext'; + +type Props = { + children: React.ReactNode +}; + +export const ContentBox: React.FC = ({children}) => { + const {color} = React.useContext(AppContext).options; + + const style = { + '--gh-accent-color': color + } as React.CSSProperties; + + return ( +
+ {children} +
+ ); +}; diff --git a/ghost/signup-form/src/components/Frame.tsx b/ghost/signup-form/src/components/Frame.tsx new file mode 100644 index 0000000000..0f5984009d --- /dev/null +++ b/ghost/signup-form/src/components/Frame.tsx @@ -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 = ({children}) => { + const style: React.CSSProperties = { + width: '100%', + height: '0px' // = default height + }; + return ( + + {children} + + ); +}; + +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 = ({children, style, title}) => { + const [iframeStyle, setIframeStyle] = useState(style); + const onResize = useCallback((iframeRoot: HTMLElement) => { + setIframeStyle((current) => { + return { + ...current, + height: `${iframeRoot.scrollHeight}px` + }; + }); + }, []); + + return ( + + {children} + + ); +}; + +type TailwindFrameProps = ResizableFrameProps & { + onResize: (el: HTMLElement) => void +}; + +/** + * Loads all the CSS styles inside an iFrame. + */ +const TailwindFrame: React.FC = ({children, onResize, style, title}) => { + const head = ( + <> +