├── .env.local ├── src ├── global.d.ts ├── components │ ├── index.ts │ ├── form.tsx │ ├── text.tsx │ ├── input.tsx │ ├── text.css.ts │ ├── input.css.ts │ ├── button.tsx │ ├── box.tsx │ └── button.css.ts ├── config │ └── env.ts ├── test-utils │ └── helpers.ts ├── hooks │ └── useWebComponentEvents.ts ├── call-to-action.spec.ts ├── pokemon.spec.ts ├── call-to-action.css.ts ├── reset.css.ts ├── vars.css.ts ├── sprinkles.css.ts ├── call-to-action.island.tsx └── pokemon.island.tsx ├── .vscode └── settings.json ├── .prettierrc ├── .gitignore ├── .github └── workflows │ └── playwright.yml ├── tsconfig.json ├── netlify.toml ├── package.json ├── FileSizePlugin.js ├── playwright.config.ts ├── README.md └── webpack.config.js /.env.local: -------------------------------------------------------------------------------- 1 | ISLAND_API_URL=https://pokeapi.co/api/v2 -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const ISLAND_API_URL: string 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": false, 5 | "endOfLine": "auto" 6 | } 7 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './box' 2 | export * from './button' 3 | export * from './input' 4 | export * from './text' 5 | export * from './form' 6 | -------------------------------------------------------------------------------- /src/config/env.ts: -------------------------------------------------------------------------------- 1 | export const isString = (x: unknown): x is string => 2 | typeof x === 'string' || x instanceof String 3 | 4 | const requiredString = (x: unknown) => { 5 | if (!isString(x)) { 6 | throw new Error(`Expected string, got ${typeof x}`) 7 | } 8 | return x 9 | } 10 | 11 | export const API_URL = requiredString(ISLAND_API_URL) 12 | -------------------------------------------------------------------------------- /src/components/form.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'preact' 2 | import { Box, BoxProps } from './box' 3 | import cx from 'clsx' 4 | 5 | export type FormProps = BoxProps & { 6 | as?: never 7 | } & Omit, 'size' | 'width'> 8 | 9 | export const Form = ({ className, onSubmit, ...rest }: FormProps) => { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/test-utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import { expect, Locator, Page } from '@playwright/test' 2 | 3 | export const getIsland = async (page: Page, name: string) => { 4 | await page.goto('./') 5 | 6 | // Expect a title "to contain" a substring. 7 | await expect(page).toHaveTitle(/Islands/) 8 | 9 | return page.locator(name) 10 | } 11 | 12 | export const getByTestId = async (locator: Locator, testId: string) => { 13 | return locator.locator(`data-testid=${testId}`) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/text.tsx: -------------------------------------------------------------------------------- 1 | import { Box, BoxProps } from './box' 2 | import { text, TextVariants } from './text.css' 3 | import cx from 'clsx' 4 | 5 | export type TextProps = BoxProps & { 6 | as?: Extract 7 | } & TextVariants 8 | 9 | /** 10 | * We are using a div here because the place you embed the island on may have global styles applied 11 | * to HTML elements. 12 | */ 13 | export const Text = ({ as = 'div', size, className, ...rest }: TextProps) => { 14 | return 15 | } 16 | -------------------------------------------------------------------------------- /src/components/input.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'preact' 2 | import { Box, BoxProps } from './box' 3 | import { input, InputVariants } from './input.css' 4 | import cx from 'clsx' 5 | 6 | export type InputProps = BoxProps & { 7 | as?: never 8 | } & Omit, 'size' | 'width'> & 9 | InputVariants 10 | 11 | export const Input = ({ size, theme, className, ...rest }: InputProps) => { 12 | return ( 13 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/useWebComponentEvents.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'preact/hooks' 2 | 3 | export const useWebComponentEvents = (name: string, parent?: string) => { 4 | useLayoutEffect(() => { 5 | const event = new CustomEvent('web-component-mount', { 6 | detail: { target: name, parent }, 7 | bubbles: true, 8 | }) 9 | 10 | dispatchEvent(event) 11 | 12 | return () => { 13 | const event = new CustomEvent('web-component-unmount', { 14 | detail: { target: name, parent }, 15 | bubbles: true, 16 | }) 17 | 18 | dispatchEvent(event) 19 | } 20 | }, [name]) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/text.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css' 2 | import { recipe, RecipeVariants } from '@vanilla-extract/recipes' 3 | import { sprinkles } from '../sprinkles.css' 4 | 5 | export const text = recipe({ 6 | variants: { 7 | size: { 8 | xs: style({ fontSize: 'xs', lineHeight: '1' }), 9 | sm: sprinkles({ fontSize: 'sm', lineHeight: '1' }), 10 | md: sprinkles({ fontSize: 'md', lineHeight: '1' }), 11 | lg: sprinkles({ fontSize: 'lg', lineHeight: '1' }), 12 | xl: sprinkles({ fontSize: 'xl', lineHeight: '1' }), 13 | }, 14 | }, 15 | defaultVariants: { 16 | size: 'md', 17 | }, 18 | }) 19 | 20 | export type TextVariants = RecipeVariants 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See http://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # Config 5 | .vscode/* 6 | !.vscode/extensions.json 7 | !.vscode/launch.json 8 | !.vscode/settings.json 9 | !.vscode/tasks.json 10 | *.sublime-workspace 11 | /.idea 12 | 13 | # Caches and OS stuff 14 | .c9/ 15 | .cache 16 | .cache 17 | .classpath 18 | .DS_Store 19 | .dynamodb 20 | 21 | # Node Modules 22 | node_modules 23 | 24 | dist 25 | 26 | # Logs 27 | lerna-debug.log 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | playwright-report 32 | size-plugin.json 33 | testem.log 34 | tests-out 35 | Thumbs.db 36 | ui-debug.log 37 | web-build/ 38 | /test-results/ 39 | /playwright-report/ 40 | /playwright/.cache/ 41 | /test-results/ 42 | /playwright-report/ 43 | /playwright/.cache/ 44 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Island Tests 2 | on: 3 | push: 4 | branches: [main, master] 5 | pull_request: 6 | branches: [main, master] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '14.x' 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: Install Playwright Browsers 19 | run: npx playwright install --with-deps 20 | - name: Run Playwright tests 21 | run: npx playwright test 22 | - uses: actions/upload-artifact@v2 23 | if: always() 24 | with: 25 | name: playwright-report 26 | path: playwright-report/ 27 | retention-days: 30 28 | -------------------------------------------------------------------------------- /src/call-to-action.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | import { getByTestId, getIsland } from './test-utils/helpers' 3 | 4 | test('should render the island allow the modal to render', async ({ page }) => { 5 | const island = await getIsland(page, 'call-to-action-island') 6 | 7 | const dimmerCount = await page.locator('starter-dimmer').count() 8 | await expect(dimmerCount).toBe(0) 9 | const modalCount = await page.locator('starter-modal').count() 10 | await expect(modalCount).toBe(0) 11 | 12 | const button = await getByTestId(island, 'callToAction') 13 | await button.click() 14 | 15 | const dimmerCount1 = await page.locator('starter-dimmer').count() 16 | await expect(dimmerCount1).toBe(1) 17 | const modalCount1 = await page.locator('starter-modal').count() 18 | await expect(modalCount1).toBe(1) 19 | }) 20 | -------------------------------------------------------------------------------- /src/components/input.css.ts: -------------------------------------------------------------------------------- 1 | import { recipe, RecipeVariants } from '@vanilla-extract/recipes' 2 | import { style } from '@vanilla-extract/css' 3 | import { inputReset } from '../reset.css' 4 | import { vars } from '../vars.css' 5 | import { sprinkles } from '../sprinkles.css' 6 | 7 | export const input = recipe({ 8 | base: [inputReset], 9 | variants: { 10 | theme: { 11 | primary: style({ 12 | border: `1px solid ${vars.color.text}`, 13 | '::placeholder': { 14 | color: vars.color.text, 15 | opacity: '0.5', 16 | fontWeight: 'normal', 17 | }, 18 | }), 19 | }, 20 | size: { 21 | sm: sprinkles({ px: '2', py: '2' }), 22 | md: sprinkles({ px: '3', py: '3' }), 23 | lg: sprinkles({ px: '4', py: '4' }), 24 | }, 25 | }, 26 | defaultVariants: { 27 | theme: 'primary', 28 | size: 'md', 29 | }, 30 | }) 31 | 32 | export type InputVariants = RecipeVariants 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "jsxImportSource": "preact", 5 | "allowJs": true, 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "noImplicitOverride": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "rootDir": ".", 13 | "sourceMap": true, 14 | "declaration": false, 15 | "moduleResolution": "node", 16 | "emitDecoratorMetadata": true, 17 | "experimentalDecorators": true, 18 | "importHelpers": true, 19 | "target": "es2015", 20 | "module": "esnext", 21 | "types": ["node"], 22 | "lib": ["es2017", "dom", "DOM.Iterable"], 23 | "skipLibCheck": true, 24 | "skipDefaultLibCheck": true, 25 | "noPropertyAccessFromIndexSignature": false, 26 | "baseUrl": "." 27 | }, 28 | "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] 29 | } 30 | -------------------------------------------------------------------------------- /src/pokemon.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | import { getByTestId, getIsland } from './test-utils/helpers' 3 | 4 | test('should render the island and allow the form to be submitted', async ({ 5 | page, 6 | }) => { 7 | // This is from .env.local 8 | await page.route('https://pokeapi.co/api/v2/**/*', async (route) => { 9 | route.fulfill({ 10 | status: 200, 11 | body: JSON.stringify({ 12 | name: 'Arcanine', 13 | id: 59, 14 | sprites: { 15 | front_default: 'https://via.placeholder.com/150', 16 | }, 17 | }), 18 | }) 19 | }) 20 | 21 | const island = await getIsland(page, 'pokemon-island') 22 | 23 | const input = await getByTestId(island, 'pokemon') 24 | await input.click() 25 | await input.fill('arcanine') 26 | 27 | const submit = await getByTestId(island, 'submitPokemon') 28 | await submit.click() 29 | 30 | await page.waitForSelector('data-testid=pokemonDetails') 31 | 32 | const details = await getByTestId(island, 'pokemonDetails') 33 | 34 | const pokemonName = await getByTestId(details, 'pokemonName') 35 | await expect(pokemonName).toHaveText('Name: Arcanine') 36 | 37 | const pokemonNumber = await getByTestId(details, 'pokemonNumber') 38 | await expect(pokemonNumber).toHaveText('Number: 59') 39 | }) 40 | -------------------------------------------------------------------------------- /src/call-to-action.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css' 2 | 3 | export const button = style({ 4 | border: 'none', 5 | backgroundColor: '#294eab', 6 | borderRadius: '5px', 7 | padding: '10px', 8 | fontWeight: 'bold', 9 | color: 'white', 10 | cursor: 'pointer', 11 | fontFamily: 'inherit', 12 | }) 13 | 14 | export const dimmer = style({ 15 | position: 'fixed', 16 | display: 'none', 17 | zIndex: '90', 18 | top: '0', 19 | left: '0', 20 | right: '0', 21 | bottom: '0', 22 | backgroundColor: 'rgba(0, 0, 0, 0.6)', 23 | }) 24 | 25 | export const dimmerVisible = style({ 26 | display: 'block', 27 | animation: 'show 0.2s', 28 | animationFillMode: 'forwards', 29 | }) 30 | 31 | export const modal = style({ 32 | position: 'fixed', 33 | outline: 'none', 34 | zIndex: '100', 35 | backgroundColor: 'white', 36 | display: 'none !important', 37 | width: '380px', 38 | borderRadius: '24px', 39 | padding: '34px 21px', 40 | left: '50%', 41 | top: '50%', 42 | transform: 'translate(-50%, -50%)', 43 | fontFamily: 'inherit', 44 | overflowY: 'auto', 45 | height: '650px', 46 | textAlign: 'center', 47 | }) 48 | 49 | export const image = style({ 50 | width: '100%', 51 | marginBottom: '1rem', 52 | }) 53 | 54 | export const modalVisible = style({ 55 | display: 'block !important', 56 | animation: 'show 0.3s', 57 | animationFillMode: 'forwards', 58 | }) 59 | -------------------------------------------------------------------------------- /src/reset.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css' 2 | import { vars } from './vars.css' 3 | 4 | export const baseReset = style({ 5 | margin: 0, 6 | padding: '0', 7 | border: 0, 8 | boxSizing: 'border-box', 9 | fontSize: '100%', 10 | fontFamily: 'inherit', 11 | verticalAlign: 'baseline', 12 | WebkitTapHighlightColor: 'transparent', 13 | color: vars.color.text, 14 | }) 15 | 16 | const inheritFontReset = style({ 17 | fontFamily: 'inherit', 18 | }) 19 | 20 | const blockReset = style({ 21 | display: 'block', 22 | }) 23 | 24 | const lineHeightReset = style({ 25 | lineHeight: '1', 26 | }) 27 | 28 | const appearanceReset = style({ 29 | appearance: 'none', 30 | }) 31 | 32 | const activeReset = style({ 33 | boxShadow: 'none', 34 | outline: 'none', 35 | }) 36 | 37 | const transparentReset = style({ 38 | backgroundColor: 'transparent', 39 | }) 40 | 41 | const field = style([ 42 | blockReset, 43 | appearanceReset, 44 | transparentReset, 45 | inheritFontReset, 46 | lineHeightReset, 47 | activeReset, 48 | ]) 49 | 50 | export const buttonReset = style([ 51 | baseReset, 52 | transparentReset, 53 | inheritFontReset, 54 | ]) 55 | 56 | export const inputReset = style([ 57 | field, 58 | style({ 59 | selectors: { 60 | '&::-ms-clear': { 61 | display: 'none', 62 | }, 63 | '&::-webkit-search-cancel-button': { 64 | WebkitAppearance: 'none', 65 | }, 66 | }, 67 | }), 68 | ]) 69 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { FunctionComponent, ComponentProps } from 'preact' 3 | import { Box, BoxProps } from './box' 4 | import { button, ButtonVariants, loadingDot, disabled } from './button.css' 5 | 6 | export type ButtonProps = BoxProps & 7 | ComponentProps<'div'> & { 8 | isLoading?: boolean 9 | disabled?: boolean 10 | onClick?: () => void 11 | } & ButtonVariants 12 | 13 | const ButtonLoader = () => ( 14 | 15 | 16 | . 17 | 18 | 19 | . 20 | 21 | 22 | . 23 | 24 | 25 | ) 26 | 27 | export const Button: FunctionComponent = ({ 28 | children, 29 | kind, 30 | size, 31 | theme, 32 | onClick, 33 | isLoading = false, 34 | disabled: disabledProp = false, 35 | className, 36 | ...rest 37 | }) => { 38 | return ( 39 | { 42 | if (disabledProp) return 43 | 44 | if (e.key === 'Enter') { 45 | onClick?.() 46 | } 47 | }} 48 | onClick={() => { 49 | if (disabledProp) return 50 | 51 | onClick?.() 52 | }} 53 | className={clsx( 54 | button({ kind, size, theme }), 55 | { [disabled]: disabledProp || isLoading }, 56 | className, 57 | )} 58 | {...rest} 59 | > 60 | {children} 61 | {isLoading ? : null} 62 | 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | # Settings in the [build] context are global and are applied to all contexts 2 | # unless otherwise overridden by more specific contexts. 3 | [build] 4 | # Directory to change to before starting a build. 5 | # This is where we will look for package.json/.nvmrc/etc. 6 | # If not set, defaults to the root directory. 7 | base = "/" 8 | 9 | # Directory that contains the deploy-ready HTML files and assets generated by 10 | # the build. This is relative to the base directory if one has been set, or the 11 | # root directory if a base has not been set. This sample publishes the 12 | # directory located at the absolute path "root/project/build-output" 13 | publish = "dist" 14 | 15 | # Default build command. 16 | # Go to the root and build so all the workspace deps are built then go to the project 17 | # directory and run commands so that they only execute in that project. 18 | # Force postinstall: https://github.com/netlify/build-image/issues/523 19 | command = "npm run build" 20 | 21 | [build.environment] 22 | ISLAND_API_URL = "https://pokeapi.co/api/v2" 23 | 24 | [context.production] 25 | environment = { ISLAND_API_URL = "https://pokeapi.co/api/v2" } 26 | 27 | # This changes settings for ANY branch that is not deployed off of production branch 28 | # We are setting this to prod here to capture any "experiments" branches we are using for A/B testing on live sites 29 | [context.branch-deploy] 30 | environment = { ISLAND_API_URL = "https://pokeapi.co/api/v2" } 31 | 32 | # we must override development back so that it will deploy dev 33 | [context.develop] 34 | environment = { ISLAND_API_URL = "https://pokeapi.co/api/v2" } 35 | -------------------------------------------------------------------------------- /src/components/box.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentChildren, ComponentProps } from 'preact' 2 | import { baseReset } from '../reset.css' 3 | import { layoutProperties, sprinkles } from '../sprinkles.css' 4 | import cx from 'clsx' 5 | 6 | /** 7 | * Parses out what properties are sprinkles compared to props 8 | */ 9 | const parsePropsFromSprinkles = (props: any) => { 10 | const componentProps: Record = {} 11 | const sprinkleProps: Record = {} 12 | 13 | for (const key in props) { 14 | // @ts-ignore not worth the effort to type the index signature 15 | if (layoutProperties['styles'][key]) { 16 | sprinkleProps[key] = props[key] 17 | } else { 18 | componentProps[key] = props[key] 19 | } 20 | } 21 | 22 | return [componentProps, sprinkleProps] 23 | } 24 | 25 | /** 26 | * Add props to the pick as you need them. 27 | */ 28 | export type BoxProps = Pick< 29 | ComponentProps<'div'>, 30 | 'role' | 'className' | 'style' | 'onClick' | 'onKeyDown' | 'href' 31 | > & { 32 | as?: any 33 | children?: ComponentChildren | ComponentChildren[] 34 | testId?: string 35 | id?: string 36 | } & Parameters[0] 37 | 38 | /** 39 | * This is a base primitive that all over elements are built off of. 40 | */ 41 | export const Box = ({ 42 | as: Component = 'div', 43 | children, 44 | className, 45 | testId, 46 | style, 47 | ...maybeSprinkles 48 | }: BoxProps) => { 49 | const [componentProps, sprinkleProps] = 50 | parsePropsFromSprinkles(maybeSprinkles) 51 | 52 | return ( 53 | 59 | {children} 60 | 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "preact-island-starter", 3 | "version": "1.0.0", 4 | "description": "No configuration starter project for Preact Island", 5 | "license": "MIT", 6 | "author": "Marcus Wood", 7 | "main": "index.js", 8 | "scripts": { 9 | "build": "NODE_ENV=production webpack --env prod", 10 | "codegen": "npx playwright codegen", 11 | "dev": "webpack serve --env dev", 12 | "test": "npx playwright test", 13 | "test-headed": "npx playwright test --headed", 14 | "test-headed-slow": "SLOW=true npx playwright test --headed", 15 | "tsc": "tsc --noEmit -p tsconfig.app.json", 16 | "prettier": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"", 17 | "prettier-fix": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"" 18 | }, 19 | "dependencies": { 20 | "@preact/preset-vite": "^2.3.0", 21 | "@vanilla-extract/css": "^1.7.2", 22 | "@vanilla-extract/dynamic": "^2.0.2", 23 | "@vanilla-extract/recipes": "^0.2.5", 24 | "@vanilla-extract/sprinkles": "^1.4.1", 25 | "clsx": "^1.2.1", 26 | "polished": "^4.2.2", 27 | "preact": "^10.5.7", 28 | "preact-island": "^1.1.0", 29 | "preact-render-to-string": "^5.1.12", 30 | "redaxios": "^0.4.1" 31 | }, 32 | "devDependencies": { 33 | "@babel/preset-env": "^7.18.9", 34 | "@babel/preset-react": "^7.18.6", 35 | "@babel/preset-typescript": "^7.18.6", 36 | "@playwright/test": "^1.25.1", 37 | "@types/webpack": "^5.28.0", 38 | "@vanilla-extract/babel-plugin": "^1.1.7", 39 | "@vanilla-extract/webpack-plugin": "^2.1.11", 40 | "babel-loader": "^8.2.5", 41 | "brotli-size": "^4.0.0", 42 | "css-loader": "^6.7.1", 43 | "dotenv": "^10.0.0", 44 | "glob": "^8.0.3", 45 | "gzip-size": "^6.0.0", 46 | "html-webpack-plugin": "^5.5.0", 47 | "kleur": "^4.1.5", 48 | "prettier": "^2.6.2", 49 | "pretty-bytes": "^5.0.0", 50 | "style-loader": "^3.3.1", 51 | "ts-node": "~10.8.0", 52 | "typescript": "^4.6.4", 53 | "webpack": "^5.74.0", 54 | "webpack-cli": "^4.10.0", 55 | "webpack-dev-server": "^4.9.3" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/vars.css.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalTheme } from '@vanilla-extract/css' 2 | import { modularScale } from 'polished' 3 | 4 | const createScale = (ratio: number, base: number) => (steps: number) => 5 | `${modularScale(steps, base, ratio)}px` 6 | 7 | const spaceScale = createScale(1.4, 4) 8 | const fontSizeScale = createScale(1.3, 16) 9 | const lineHeightScale = createScale(1.25, 24) 10 | const borderRadiusScale = createScale(1.5, 4) 11 | 12 | export const mediaQueries = { 13 | tablet: 'screen and (min-width: 768px)', 14 | desktop: 'screen and (min-width: 1024px)', 15 | } 16 | 17 | // Host because it's going inside of a web component! 18 | export const vars = createGlobalTheme(':host', { 19 | space: { 20 | nudge: '-1px', 21 | '0': '0', 22 | '1': spaceScale(1), 23 | '2': spaceScale(2), 24 | '3': spaceScale(3), 25 | '4': spaceScale(4), 26 | '5': spaceScale(5), 27 | '6': spaceScale(6), 28 | '7': spaceScale(7), 29 | '8': spaceScale(8), 30 | }, 31 | borderRadius: { 32 | '1': borderRadiusScale(0), 33 | '2': borderRadiusScale(1), 34 | '3': borderRadiusScale(2), 35 | '4': borderRadiusScale(3), 36 | '5': borderRadiusScale(4), 37 | '6': borderRadiusScale(5), 38 | full: '99999px', 39 | }, 40 | border: { 41 | none: 'none', 42 | sm: '1px solid', 43 | md: '2px solid', 44 | lg: '5px solid', 45 | }, 46 | color: { 47 | white: '#fff', 48 | black: '#000', 49 | text: '#0E0E0E', 50 | gray: '#848484', 51 | transparent: 'transparent', 52 | primaryColor: '#52528C', 53 | secondaryColor: '#7C9EB2', 54 | }, 55 | fontSize: { 56 | xs: fontSizeScale(-2), 57 | sm: fontSizeScale(-1), 58 | md: fontSizeScale(0), 59 | lg: fontSizeScale(1), 60 | xl: fontSizeScale(2), 61 | xxl: fontSizeScale(3), 62 | xxxl: fontSizeScale(4), 63 | }, 64 | lineHeight: { 65 | '1': lineHeightScale(0), 66 | '2': lineHeightScale(1), 67 | '3': lineHeightScale(2), 68 | '4': lineHeightScale(3), 69 | '5': lineHeightScale(4), 70 | '6': lineHeightScale(5), 71 | }, 72 | }) 73 | -------------------------------------------------------------------------------- /FileSizePlugin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Gets size stats for built islands. 3 | */ 4 | const { basename, join } = require('path') 5 | const { green, red, yellow, white } = require('kleur') 6 | const gzipSize = require('gzip-size') 7 | const brotliSize = require('brotli-size') 8 | const prettyBytes = require('pretty-bytes') 9 | const fs = require('fs/promises') 10 | 11 | /** 12 | * 13 | * Sauce pulled from here: 14 | * https://github.com/developit/microbundle/blob/ecb0b022912397bcf98550c1a783e9e0534f33e5/src/lib/compressed-size.js 15 | */ 16 | function getPadLeft(str, width, char = ' ') { 17 | return char.repeat(width - str.length) 18 | } 19 | 20 | function formatSize(size, filename, suffix, raw) { 21 | const pretty = raw ? `${size} B` : prettyBytes(size) 22 | const color = size < 5000 ? green : size > 40000 ? red : yellow 23 | const indent = getPadLeft(pretty, 13) 24 | return `${indent}${color(pretty)}: ${white(basename(filename))}${suffix}` 25 | } 26 | 27 | async function getSizeInfo(path, filename) { 28 | const code = await fs.readFile(path) 29 | 30 | const [original, gzip, brotli] = await Promise.all([ 31 | fs.stat(path).then((x) => x.size), 32 | gzipSize(code).catch(() => null), 33 | brotliSize.sync(code), 34 | ]) 35 | 36 | const raw = original < 5000 37 | 38 | let out = formatSize(original, filename, '', raw) 39 | 40 | out += '\n' + formatSize(gzip, filename, '.gz', raw) 41 | 42 | if (brotli) { 43 | out += '\n' + formatSize(brotli, filename, '.br', raw) 44 | } 45 | 46 | return out 47 | } 48 | 49 | class FileSizePlugin { 50 | apply(compiler) { 51 | compiler.hooks.done.tap( 52 | 'File Size Plugin', 53 | async ( 54 | stats /* stats is passed as an argument when done hook is tapped. */, 55 | ) => { 56 | const promises = [] 57 | stats.compilation.assetsInfo.forEach((value, key) => { 58 | const filePath = join(stats.compilation.outputOptions.path, key) 59 | promises.push(getSizeInfo(filePath, key)) 60 | }) 61 | 62 | const resolve = await Promise.all(promises) 63 | 64 | resolve.map((log) => console.log(log)) 65 | }, 66 | ) 67 | } 68 | } 69 | 70 | module.exports = FileSizePlugin 71 | -------------------------------------------------------------------------------- /src/sprinkles.css.ts: -------------------------------------------------------------------------------- 1 | import { defineProperties, createSprinkles } from '@vanilla-extract/sprinkles' 2 | import { mediaQueries, vars } from './vars.css' 3 | 4 | export const layoutProperties = defineProperties({ 5 | conditions: { 6 | mobile: {}, 7 | tablet: { '@media': mediaQueries.tablet }, 8 | desktop: { '@media': mediaQueries.desktop }, 9 | }, 10 | defaultCondition: 'mobile', 11 | properties: { 12 | // None always has to be at the end! 13 | display: ['block', 'flex', 'inline-flex', 'inline', 'inline-block', 'none'], 14 | position: ['absolute', 'relative', 'fixed'], 15 | flexDirection: ['row', 'column'], 16 | flexWrap: ['nowrap', 'wrap'], 17 | flexShrink: [0, 1], 18 | alignItems: ['stretch', 'flex-start', 'center', 'flex-end'], 19 | justifyContent: [ 20 | 'stretch', 21 | 'flex-start', 22 | 'center', 23 | 'flex-end', 24 | 'space-between', 25 | ], 26 | width: ['100%', '25%', '50%', 'auto'], 27 | textAlign: ['left', 'center', 'right'], 28 | fontWeight: ['normal', 'inherit', 'bold', 500, 300], 29 | gap: vars.space, 30 | paddingTop: vars.space, 31 | paddingBottom: vars.space, 32 | paddingLeft: vars.space, 33 | paddingRight: vars.space, 34 | marginTop: vars.space, 35 | marginBottom: vars.space, 36 | marginLeft: vars.space, 37 | marginRight: vars.space, 38 | borderRadius: vars.borderRadius, 39 | fontSize: vars.fontSize, 40 | lineHeight: vars.lineHeight, 41 | // Order matters here, border color always at the bottom so the style can override! 42 | border: vars.border, 43 | borderTop: vars.border, 44 | borderBottom: vars.border, 45 | borderColor: vars.color, 46 | background: vars.color, 47 | color: vars.color, 48 | }, 49 | shorthands: { 50 | p: ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'], 51 | px: ['paddingLeft', 'paddingRight'], 52 | py: ['paddingTop', 'paddingBottom'], 53 | pt: ['paddingTop'], 54 | pb: ['paddingBottom'], 55 | pr: ['paddingRight'], 56 | pl: ['paddingLeft'], 57 | m: ['marginTop', 'marginBottom', 'marginLeft', 'marginRight'], 58 | mx: ['marginLeft', 'marginRight'], 59 | my: ['marginTop', 'marginBottom'], 60 | mt: ['marginTop'], 61 | mb: ['marginBottom'], 62 | mr: ['marginRight'], 63 | ml: ['marginLeft'], 64 | }, 65 | responsiveArray: ['mobile', 'tablet', 'desktop'], 66 | }) 67 | 68 | export const sprinkles = createSprinkles(layoutProperties) 69 | -------------------------------------------------------------------------------- /src/call-to-action.island.tsx: -------------------------------------------------------------------------------- 1 | import './reset.css' 2 | 3 | import { createIslandWebComponent, WebComponentPortal } from 'preact-island' 4 | import { useState } from 'preact/hooks' 5 | import cx from 'clsx' 6 | import { Box, Button, Text } from './components' 7 | import * as styles from './call-to-action.css' 8 | import { FC } from 'preact/compat' 9 | import { useWebComponentEvents } from './hooks/useWebComponentEvents' 10 | 11 | const islandName = 'call-to-action-island' 12 | 13 | const Portalize: FC<{ name: string; parent: string }> = ({ 14 | children, 15 | name, 16 | parent, 17 | }) => { 18 | useWebComponentEvents(name, parent) 19 | 20 | // @ts-ignore types are wrong 21 | return {children} 22 | } 23 | 24 | export const CallToAction = ({ 25 | backgroundColor, 26 | }: { 27 | backgroundColor?: string 28 | }) => { 29 | const [isOpen, setIsOpen] = useState(false) 30 | 31 | useWebComponentEvents(islandName) 32 | 33 | return ( 34 |
35 | 43 | 44 | {isOpen && ( 45 | 46 | 50 | 54 | Portals work with web component islands too! 55 | 58 | 59 | 60 | )} 61 | {isOpen && ( 62 | 63 | setIsOpen(false)} 67 | /> 68 | 69 | )} 70 |
71 | ) 72 | } 73 | 74 | const island = createIslandWebComponent(islandName, CallToAction) 75 | island.render({ 76 | selector: islandName, 77 | }) 78 | -------------------------------------------------------------------------------- /src/pokemon.island.tsx: -------------------------------------------------------------------------------- 1 | import './reset.css' 2 | 3 | import { createIslandWebComponent } from 'preact-island' 4 | import { Box, Button, Input, Text, Form } from './components' 5 | import { useState } from 'preact/hooks' 6 | import axios from 'redaxios' 7 | import { API_URL } from './config/env' 8 | import { JSXInternal } from 'preact/src/jsx' 9 | import { useWebComponentEvents } from './hooks/useWebComponentEvents' 10 | 11 | const islandName = 'pokemon-island' 12 | 13 | export const Pokemon = () => { 14 | useWebComponentEvents(islandName) 15 | const [pokemonDetails, setPokemonDetails] = useState<{ 16 | name: string 17 | sprite: string 18 | number: number 19 | } | null>(null) 20 | const [pokemonInput, setPokemonInput] = useState('') 21 | const [pokemonError, setPokemonError] = useState( 22 | null, 23 | ) 24 | const [pokemonLoading, setPokemonLoading] = useState(false) 25 | 26 | const onSubmit = async () => { 27 | setPokemonLoading(true) 28 | const resp = await axios 29 | .get(`${API_URL}/pokemon/${pokemonInput}`) 30 | .catch((err) => { 31 | setPokemonDetails(null) 32 | setPokemonError(An error ocurred.) 33 | }) 34 | .finally(() => { 35 | setPokemonLoading(false) 36 | }) 37 | 38 | if (!resp) { 39 | setPokemonDetails(null) 40 | setPokemonError(Pokemon not found) 41 | return 42 | } 43 | 44 | setPokemonError(null) 45 | setPokemonDetails({ 46 | name: resp.data.name, 47 | number: resp.data.id, 48 | sprite: resp.data.sprites.front_default, 49 | }) 50 | } 51 | 52 | return ( 53 | 54 | 55 | Search a pokemon 56 | 57 |
{ 60 | e.preventDefault() 61 | onSubmit 62 | }} 63 | > 64 | setPokemonInput((e.target as HTMLInputElement).value)} 70 | /> 71 | 78 | {pokemonError} 79 |
80 | 81 | {pokemonDetails != null ? ( 82 | 83 | Pokemon Details 84 | 85 | 86 | 87 | 88 | 89 | 90 | Name: {pokemonDetails.name} 91 | 92 | 93 | Number: {pokemonDetails.number} 94 | 95 | 96 | 97 | 98 | ) : ( 99 | Submit a pokemon to see details. 100 | )} 101 | 102 |
103 | ) 104 | } 105 | 106 | createIslandWebComponent(islandName, Pokemon).render({ 107 | selector: islandName, 108 | initialProps: {}, 109 | }) 110 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test' 2 | import { devices } from '@playwright/test' 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | const config: PlaywrightTestConfig = { 14 | testDir: './src', 15 | /* Maximum time one test can run for. */ 16 | timeout: 20 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000, 23 | }, 24 | webServer: { 25 | command: 'npm run dev', 26 | url: 'http://localhost:7777/', 27 | timeout: 120 * 1000, 28 | reuseExistingServer: !process.env.CI, 29 | }, 30 | /* Run tests in files in parallel */ 31 | fullyParallel: true, 32 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 33 | forbidOnly: !!process.env.CI, 34 | /* Retry on CI only */ 35 | retries: process.env.CI ? 2 : 0, 36 | /* Opt out of parallel tests on CI. */ 37 | workers: process.env.CI ? 1 : undefined, 38 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 39 | reporter: 'html', 40 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 41 | use: { 42 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 43 | actionTimeout: 0, 44 | /* Base URL to use in actions like `await page.goto('/')`. */ 45 | // baseURL: 'http://localhost:3000', 46 | 47 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 48 | video: 'retain-on-failure', 49 | trace: 'retain-on-failure', 50 | 51 | baseURL: 'http://localhost:7777/', 52 | launchOptions: { 53 | slowMo: process.env.SLOW === 'true' ? 3000 : 0, 54 | }, 55 | }, 56 | 57 | /* Configure projects for major browsers */ 58 | projects: [ 59 | { 60 | name: 'chromium', 61 | use: { 62 | ...devices['Desktop Chrome'], 63 | }, 64 | }, 65 | 66 | // { 67 | // name: 'firefox', 68 | // use: { 69 | // ...devices['Desktop Firefox'], 70 | // }, 71 | // }, 72 | 73 | // { 74 | // name: 'webkit', 75 | // use: { 76 | // ...devices['Desktop Safari'], 77 | // }, 78 | // }, 79 | 80 | /* Test against mobile viewports. */ 81 | // { 82 | // name: 'Mobile Chrome', 83 | // use: { 84 | // ...devices['Pixel 5'], 85 | // }, 86 | // }, 87 | // { 88 | // name: 'Mobile Safari', 89 | // use: { 90 | // ...devices['iPhone 12'], 91 | // }, 92 | // }, 93 | 94 | /* Test against branded browsers. */ 95 | // { 96 | // name: 'Microsoft Edge', 97 | // use: { 98 | // channel: 'msedge', 99 | // }, 100 | // }, 101 | // { 102 | // name: 'Google Chrome', 103 | // use: { 104 | // channel: 'chrome', 105 | // }, 106 | // }, 107 | ], 108 | 109 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 110 | // outputDir: 'test-results/', 111 | 112 | /* Run your local dev server before starting the tests */ 113 | // webServer: { 114 | // command: 'npm run start', 115 | // port: 3000, 116 | // }, 117 | } 118 | 119 | export default config 120 | -------------------------------------------------------------------------------- /src/components/button.css.ts: -------------------------------------------------------------------------------- 1 | import { recipe, RecipeVariants } from '@vanilla-extract/recipes' 2 | import { buttonReset } from '../reset.css' 3 | import { sprinkles } from '../sprinkles.css' 4 | import { keyframes, style } from '@vanilla-extract/css' 5 | import { vars } from '../vars.css' 6 | 7 | export const button = recipe({ 8 | base: [ 9 | buttonReset, 10 | sprinkles({ 11 | width: '100%', 12 | alignItems: 'center', 13 | justifyContent: 'center', 14 | }), 15 | style({ 16 | cursor: 'pointer', 17 | textDecoration: 'none', 18 | }), 19 | ], 20 | variants: { 21 | kind: { 22 | button: sprinkles({ display: 'inline-flex' }), 23 | link: style([ 24 | sprinkles({ display: 'inline-flex' }), 25 | { 26 | textDecoration: 'underline', 27 | }, 28 | ]), 29 | }, 30 | theme: { 31 | primary: {}, 32 | secondary: {}, 33 | }, 34 | size: { 35 | sm: {}, 36 | md: {}, 37 | lg: {}, 38 | }, 39 | }, 40 | defaultVariants: { 41 | kind: 'button', 42 | theme: 'primary', 43 | size: 'md', 44 | }, 45 | compoundVariants: [ 46 | { 47 | variants: { 48 | kind: 'button', 49 | size: 'sm', 50 | }, 51 | style: sprinkles({ px: '4', py: '2' }), 52 | }, 53 | { 54 | variants: { 55 | kind: 'button', 56 | size: 'md', 57 | }, 58 | style: sprinkles({ px: '6', py: '3' }), 59 | }, 60 | { 61 | variants: { 62 | kind: 'button', 63 | size: 'lg', 64 | }, 65 | style: sprinkles({ px: '8', py: '4' }), 66 | }, 67 | { 68 | variants: { 69 | kind: 'link', 70 | size: 'sm', 71 | }, 72 | style: sprinkles({ px: '0', py: '2' }), 73 | }, 74 | { 75 | variants: { 76 | kind: 'link', 77 | size: 'md', 78 | }, 79 | style: sprinkles({ px: '0', py: '3' }), 80 | }, 81 | { 82 | variants: { 83 | kind: 'link', 84 | size: 'lg', 85 | }, 86 | style: sprinkles({ px: '0', py: '4' }), 87 | }, 88 | { 89 | variants: { 90 | kind: 'button', 91 | theme: 'primary', 92 | }, 93 | style: [ 94 | sprinkles({ color: 'white' }), 95 | style({ 96 | backgroundColor: vars.color.primaryColor, 97 | }), 98 | ], 99 | }, 100 | { 101 | variants: { 102 | kind: 'button', 103 | theme: 'secondary', 104 | }, 105 | style: [ 106 | sprinkles({ color: 'text' }), 107 | style({ backgroundColor: vars.color.secondaryColor }), 108 | ], 109 | }, 110 | ], 111 | }) 112 | 113 | export const disabled = style({ 114 | cursor: 'not-allowed', 115 | }) 116 | 117 | const dot1 = keyframes({ 118 | '14%': { 119 | opacity: 0, 120 | }, 121 | '15%,100%': { 122 | opacity: 1, 123 | }, 124 | }) 125 | 126 | const dot2 = keyframes({ 127 | '29%': { 128 | opacity: 0, 129 | }, 130 | '30%,100%': { 131 | opacity: 1, 132 | }, 133 | }) 134 | 135 | const dot3 = keyframes({ 136 | '44%': { 137 | opacity: 0, 138 | }, 139 | '45%,100%': { 140 | opacity: 1, 141 | }, 142 | }) 143 | 144 | export const loadingDot = style({ 145 | animationDuration: '1s', 146 | animationIterationCount: 'infinite', 147 | opacity: 0, 148 | selectors: { 149 | [`&:nth-child(1)`]: { 150 | animationName: dot1, 151 | }, 152 | [`&:nth-child(2)`]: { 153 | animationName: dot2, 154 | }, 155 | [`&:nth-child(3)`]: { 156 | animationName: dot3, 157 | }, 158 | }, 159 | }) 160 | 161 | export type ButtonVariants = RecipeVariants 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

🏝 Preact Island Starter

6 |

7 | Bootstraps a Preact Island project with no configuration. 8 |

9 | 10 | [![downloads][downloads-badge]][npmcharts] 11 | [![version][version-badge]][package] 12 | [![Supports Preact and React][preact-badge]][preact] 13 | [![MIT License][license-badge]][license] 14 | 15 |
16 | 17 | ## Features 18 | 19 | - 🚀 Multi entry point builds by default. Make all the islands you need! 20 | - 🌲 Infinitely tree shakeable. Each entry point tree shakes both the JS and CSS. 21 | - 🧩 First class web component support (including web component portals 🤯) 22 | - 🧁 Zero runtime styles in Typescript thanks to [vanilla-extract](https://vanilla-extract.style/) 23 | - ⛷️ Dev environment injects scripts just like you would use in production. 24 | - 🐿️ Dynamic island build environment thanks to Webpack layers 25 | - 🚢 Built in Netlify deployments 26 | - 🙏 Environmental variable support 27 | - 🔥 Playwright testing built in 28 | - 👔 Fully typed with TypeScript 29 | 30 | ## Stack 31 | 32 | - ⚛️ Preact 33 | - 👔 TypeScript 34 | - 🌐 Webpack 5 35 | - 🧁 Vanilla-Extract 36 | - 🤡 Netlify 37 | - 🔥 Playwright testing 38 | 39 | ## What's Preact Island? 40 | 41 | Sometimes you need to embed a component onto someone else's website. This could be a Shopify widget, email sign up form, CMS comment list, social media share icon, etc. Creating these experiences are tedious and difficult because you aren't in control of the website your code will be executed on. 42 | 43 | Preact Island helps you build these experiences by adding a lightweight layer on top of Preact. For <5kB, you get a React style workflow (with hooks!), and a framework for rendering your widget with reactive props. 44 | 45 | Head on over [to the repo](https://github.com/mwood23/preact-island) for more details! 46 | 47 | ## Using the Template 48 | 49 | No fancy CLI (yet), so to use the template we're going old school! 50 | 51 | ```sh 52 | git clone git@github.com:mwood23/preact-island-starter.git 53 | 54 | cd 55 | 56 | # Remove the Git history from the repo 57 | rm -rf .git 58 | 59 | # Edit the name in the package.json 60 | 61 | # Create a new Git history 62 | git init 63 | git add . 64 | git commit -m "Initial commit" 65 | 66 | 67 | ############################################################# 68 | # From here, create a new repo, hook up the remote and push # 69 | ############################################################# 70 | 71 | # Node 16 is recommended! 72 | # To run the app 73 | npm install 74 | 75 | npm run dev 76 | ``` 77 | 78 | ## API 79 | 80 | ### Adding Islands 81 | 82 | To add a new island, create a file suffixed with `.island.tsx`. The webpack compiler will automatically pick it up and add your new island to the index.html page. You may need to restart your development server to see the changes take hold. 83 | 84 | ### Styling Islands 85 | 86 | This template uses [vanilla-extract](https://vanilla-extract.style/) for all styles. Please refer to their docs for more information. The starter has some base patterns set up, including a `Box` component that everything is built off of. There are some footguns with vanilla extract due to how CSS is interpreted by browsers so watch out! 87 | 88 | - If you use a `style()` object those are going to have higher specificity than any `sprinkles`, including props passed directly to a `` 89 | - Make sure `reset.css` is imported at the top of every island. This makes sure it is executed first in the stylesheet so that your styles can override it. 90 | 91 | ### Deploying Islands 92 | 93 | Run `npm run build` to create your islands and a demo page that you can deploy anywhere. These are static files so it's best to go somewhere with a good CDN like Vercel, Cloudflare, Netlify, etc. The islands are in a separate directory `/islands` so you don't pollute the root domain. You can alter this output in the `webpack.config.js` if you need. 94 | 95 | ### Environmental Variables 96 | 97 | The starter ships with support with environmental variables. To develop locally, add variables to `.env.local`. The starter uses Netlify for the CI and deployment process so that is where you would add variables per environment if you choose to use them for deployment. 98 | 99 | > Remember that nearly all islands are going to run on a client somewhere. These are meant to be use to create environments, all variables will be exposed onto the client (aka public), so don't put anything secretive in here! 100 | 101 | ## Credits 102 | 103 | Artwork by [vik4graphic](https://lottiefiles.com/vik4graphic) 104 | 105 | ## License 106 | 107 | [MIT](LICENSE) - Copyright (c) [Marcus Wood](https://www.marcuswood.io/) 108 | 109 | [version-badge]: https://img.shields.io/npm/v/preact-island.svg?style=flat-square 110 | [package]: https://www.npmjs.com/package/preact-island 111 | [downloads-badge]: https://img.shields.io/npm/dm/preact-island.svg?style=flat-square 112 | [npmcharts]: http://npmcharts.com/compare/preact-island 113 | [license-badge]: https://img.shields.io/npm/l/preact-island.svg?style=flat-square 114 | [license]: https://github.com/mwood23/preact-island/blob/master/LICENSE 115 | [preact-badge]: https://img.shields.io/badge/%E2%9A%9B%EF%B8%8F-preact-6F2FBF.svg?style=flat-square 116 | [preact]: https://preactjs.com 117 | [module-formats-badge]: https://img.shields.io/badge/module%20formats-umd%2C%20cjs%2C%20es-green.svg?style=flat-square 118 | [github-star]: https://github.com/mwood23/preact-island/stargazers 119 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { VanillaExtractPlugin } = require('@vanilla-extract/webpack-plugin') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | const TerserPlugin = require('terser-webpack-plugin') 5 | const { DefinePlugin } = require('webpack') 6 | const FileSizePlugin = require('./FileSizePlugin') 7 | const glob = require('glob') 8 | 9 | /** 10 | * @returns {Array.<{import: string, name: string, layer: string, elementName: string}>} 11 | */ 12 | const getIslands = () => { 13 | const paths = glob.sync('./src/**/*.island.{ts,tsx}') 14 | 15 | return paths.map((path) => { 16 | const name = path 17 | .split('/') 18 | .pop() 19 | .replace(/.island.(tsx|ts)/g, '') 20 | 21 | let elementName = `${name}-island` 22 | /** 23 | * If you want to name your web component something different than the filename of the island (not 24 | * recommended). Please override them here. 25 | */ 26 | // if (name === 'call-to-action') { 27 | // elementName = 'something-else' 28 | // } 29 | 30 | return { 31 | path, 32 | name, 33 | elementName, 34 | layer: name, 35 | } 36 | }) 37 | } 38 | 39 | const islands = getIslands() 40 | 41 | // This builds the entry points for all of your islands. 42 | const buildEntryPoints = () => { 43 | const entryPoints = {} 44 | 45 | islands.forEach((island) => { 46 | entryPoints[island.name] = { 47 | import: island.path, 48 | layer: island.layer, 49 | } 50 | }) 51 | 52 | return entryPoints 53 | } 54 | 55 | const buildCssLayersFromEntryPoints = () => { 56 | return islands.map(({ layer, elementName }) => { 57 | return { 58 | issuerLayer: layer, 59 | use: [ 60 | /** 61 | * This injects the built styles as a single style tag in the UMD bundle for the project. 62 | * This makes it to where consumers do not need to worry about an external stylesheet and 63 | * saves a request on shopify websites where the waterfall is normally clogged. 64 | */ 65 | { 66 | loader: 'style-loader', 67 | options: { 68 | injectType: 'singletonStyleTag', 69 | attributes: { 70 | 'data-style-for': elementName, 71 | }, 72 | /** 73 | * It appears the node given to you is initially blank with styles applied after the fact so you 74 | * can't rely on it to have information you need immediately. 75 | * 76 | * See: https://github.com/webpack-contrib/style-loader/blob/43bede4415c5ccb4680d558725e0066f715aa175/src/runtime/singletonStyleDomAPI.js#L83 77 | * 78 | * NOTE: This runs untranspiled in the browser so watch out! 79 | */ 80 | insert: (styleTag) => { 81 | var styleTarget = styleTag.dataset.styleFor 82 | 83 | if (!styleTarget) { 84 | console.error( 85 | 'Did not get a style target in the insert command from the style loader. No styles will be inserted. Did you override something in getIslands incorrectly?', 86 | ) 87 | 88 | return 89 | } 90 | 91 | window.addEventListener('web-component-mount', (e) => { 92 | if ( 93 | styleTarget !== e.detail.target && 94 | styleTarget !== e.detail.parent 95 | ) { 96 | return 97 | } 98 | 99 | var target = document.querySelector(e.detail.target).shadowRoot 100 | 101 | if (!target) { 102 | console.error( 103 | `Could not find a web component query selector target for "${styleTarget}". No styles will be appended. Did you name the web component at createIslandWebComponent something different than your file name? If so, you will need to override it at getIslands inside of the webpack config. This is what is expected 104 | 105 | createIslandWebComponent('${styleTarget}', YourComponent).render({ 106 | selector: ${styleTarget}, 107 | initialProps: {}, 108 | })`, 109 | ) 110 | return 111 | } 112 | 113 | // We need to clone because it's going to be inserted into separate shadow doms. If you don't clone it 114 | // the tag can only be active in one context 115 | target.prepend(styleTag.cloneNode(true)) 116 | }) 117 | }, 118 | }, 119 | }, 120 | 'css-loader', 121 | ], 122 | } 123 | }) 124 | } 125 | 126 | module.exports = ({ dev, prod }) => { 127 | const isDev = dev === true 128 | const isProd = prod === true 129 | 130 | if (isDev) { 131 | console.log( 132 | "Stubbing environmental variables for development from './env.local'", 133 | ) 134 | require('dotenv').config({ path: './.env.local' }) 135 | } 136 | 137 | /** @type { import('webpack').Configuration } */ 138 | const config = { 139 | mode: isProd ? 'production' : 'development', 140 | target: 'web', 141 | resolve: { 142 | extensions: ['.js', '.json', '.ts', '.tsx'], 143 | /** 144 | * From the docs to make Webpack compile Preact: 145 | * https://preactjs.com/guide/v10/getting-started#aliasing-in-webpack 146 | */ 147 | alias: { 148 | react: 'preact/compat', 149 | 'react-dom/test-utils': 'preact/test-utils', 150 | 'react-dom': 'preact/compat', // Must be below test-utils 151 | 'react/jsx-runtime': 'preact/jsx-runtime', 152 | }, 153 | }, 154 | devServer: { 155 | port: 7777, 156 | hot: false, 157 | }, 158 | devtool: isDev ? 'eval' : false, 159 | entry: buildEntryPoints(), 160 | output: { 161 | path: path.join(__dirname, 'dist/islands'), 162 | filename: '[name].island.umd.js', 163 | libraryTarget: 'umd', 164 | }, 165 | module: { 166 | rules: [ 167 | { 168 | test: /\.(js|ts|tsx)$/, 169 | exclude: [/node_modules/], 170 | use: [ 171 | { 172 | loader: 'babel-loader', 173 | options: { 174 | babelrc: false, 175 | presets: [ 176 | '@babel/preset-typescript', 177 | ['@babel/preset-react', { runtime: 'automatic' }], 178 | [ 179 | '@babel/preset-env', 180 | { targets: { node: 16 }, modules: false }, 181 | ], 182 | ], 183 | plugins: ['@vanilla-extract/babel-plugin'], 184 | }, 185 | }, 186 | ], 187 | }, 188 | { 189 | test: /\.css$/i, 190 | oneOf: buildCssLayersFromEntryPoints(), 191 | }, 192 | { 193 | test: /\.(png|jpe?g|gif)$/i, 194 | use: [ 195 | { 196 | loader: 'file-loader', 197 | }, 198 | ], 199 | }, 200 | ], 201 | }, 202 | plugins: [ 203 | new HtmlWebpackPlugin({ 204 | inject: false, 205 | templateContent: ({ htmlWebpackPlugin }) => ` 206 | 207 | 208 | 209 | Islands 210 | 211 | 234 | ${htmlWebpackPlugin.tags.headTags} 235 | 236 | 237 | ${islands 238 | .map((island) => { 239 | return `
240 | <${island.elementName}> 241 |
` 242 | }) 243 | .join('')} 244 | 245 | ${htmlWebpackPlugin.tags.bodyTags} 246 | 247 | 248 | `, 249 | /** 250 | * Islands are served from /islands in dist so we don't pollute the root domain since these islands are 251 | * embedded into websites we do not control. 252 | * 253 | * In dev mode, we serve islands and the index.html from the root since it's dev mode. For production, 254 | * the index.html file is served from the root. 255 | */ 256 | publicPath: isDev ? '/' : '/islands', 257 | filename: isDev ? 'index.html' : '../index.html', 258 | }), 259 | new VanillaExtractPlugin(), 260 | /** 261 | * Define environmental variables here that you need for the islands to function. 262 | */ 263 | new DefinePlugin({ 264 | ISLAND_API_URL: JSON.stringify(process.env.ISLAND_API_URL), 265 | }), 266 | ...(isProd ? [new FileSizePlugin()] : []), 267 | ], 268 | stats: 'errors-warnings', 269 | experiments: { 270 | layers: true, 271 | }, 272 | optimization: { 273 | minimize: true, 274 | minimizer: [new TerserPlugin()], 275 | }, 276 | } 277 | 278 | return config 279 | } 280 | --------------------------------------------------------------------------------