├── .gitignore ├── LICENSE ├── README.md ├── lerna.json ├── package.json └── packages ├── tropical-islands ├── README.md ├── package.json └── src │ ├── client.jsx │ ├── index.js │ └── server.jsx └── tropical-scaffold ├── README.md ├── bin └── tropical-scaffold.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | yarn.lock 4 | lerna-debug.log 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ben Smithett 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `tropical-utils` 2 | 3 | Utility packages built for [Tropical](https://tropical.js.org/). 4 | 5 | ## Packages 6 | 7 | - [tropical-islands](packages/tropical-islands) 8 | - [tropical-scaffold](packages/tropical-scaffold) 9 | 10 | ## Publishing 11 | 12 | ```bash 13 | yarn workspace tropical-islands build 14 | npx lerna changed 15 | npx lerna publish --no-push 16 | ``` 17 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*"], 3 | "version": "independent", 4 | "npmClient": "yarn", 5 | "useWorkspaces": true 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "devDependencies": { 8 | "lerna": "^4.0.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/tropical-islands/README.md: -------------------------------------------------------------------------------- 1 | # `tropical-islands` 2 | 3 | Simple islands architecture helpers for React. Built for [Tropical](https://tropical.js.org/) but usable wherever you use React. 4 | 5 | ```bash 6 | npm install tropical-islands 7 | ``` 8 | 9 | ## Islands Architecture 10 | 11 | [Islands Architecture](https://jasonformat.com/islands-architecture/) refers to the [old-fashioned](https://www.bensmithett.com/declarative-js-components-with-viewloader-js/) practice of selectively, progressively enhancing bits of server-rendered HTML with client-side JS. 12 | 13 | The React community has taken a while to get here. Major frameworks defaulted to downloading and hydrating the entire page as a single root-level component, even if most components that make up the page needed no client-side enhancements. 14 | 15 | Instead we can take a [partial hydration](https://markus.oberlehner.net/blog/partial-hydration-concepts-lazy-and-active/) approach, selectively hydrating the parts of the page that need it. 16 | 17 | ## Usage 18 | 19 | ### Server: `` 20 | 21 | When composing server-rendered (SSR) pages, draw islands around pieces of the page that need to be hydrated with client-side JS. 22 | 23 | ```javascript 24 | import { Island } from 'tropical-islands' 25 | import { InteractiveComponent } from './InteractiveComponent' 26 | import { StaticComponent } from './StaticComponent' 27 | 28 | export function MyPage () { 29 |
30 | 31 | 32 | 33 | 34 | 35 |
36 | } 37 | ``` 38 | 39 | `` renders your component inside a div with some data attributes that will be used for client-side [hydration](https://reactjs.org/docs/react-dom.html#hydrate): 40 | 41 | ```html 42 |
43 | 44 |
48 | 49 |
50 | 51 |
52 | ``` 53 | 54 | #### Props 55 | 56 | - `componentName` *(required)* the name of the component in the object passed to `hydrateIslands` 57 | - `islandTag` *(optional, default: `'div'`)* the HTML wrapper tag 58 | - `islandProps` *(optional, default: `{}`)* props to pass to `islandTag` for server-render 59 | 60 | ### Client: `hydrateIslands(components, Providers)` 61 | 62 | From your client-side JS, call `hydrateIslands` with an object listing all the components you wish to hydrate. 63 | 64 | ```javascript 65 | import { hydrateIslands } from 'tropical-islands' 66 | import { InteractiveComponent } from './InteractiveComponent' 67 | import { OtherInteractiveComponent } from './OtherInteractiveComponent' 68 | 69 | hydrateIslands({ 70 | InteractiveComponent, 71 | OtherInteractiveComponent 72 | }) 73 | ``` 74 | 75 | #### Arguments 76 | 77 | - `components` *(required)* an object containing components, keyed by the `componentName` passed to a server-side `` 78 | - `Providers` *(optional, default: `({ children }) => children`)* a component that doesn't render any HTML but can be used to wrap your own component with context [providers](https://reactjs.org/docs/context.html#contextprovider). 79 | -------------------------------------------------------------------------------- /packages/tropical-islands/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tropical-islands", 3 | "version": "2.0.0", 4 | "description": "Simple islands architecture helpers for React", 5 | "keywords": [ 6 | "react", 7 | "tropical", 8 | "islands" 9 | ], 10 | "author": "Ben Smithett ", 11 | "homepage": "https://github.com/bensmithett/tropical-utils", 12 | "license": "MIT", 13 | "main": "lib/index.js", 14 | "directories": { 15 | "lib": "lib" 16 | }, 17 | "files": [ 18 | "lib" 19 | ], 20 | "type": "module", 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/bensmithett/tropical-utils.git" 24 | }, 25 | "scripts": { 26 | "build": "esbuild src/client.jsx src/server.jsx src/index.js --outdir=lib --bundle --external:react --external:react-dom --format=esm" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/bensmithett/tropical-utils/issues" 30 | }, 31 | "peerDependencies": { 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0" 34 | }, 35 | "devDependencies": { 36 | "esbuild": "^0.14.5" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/tropical-islands/src/client.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { hydrateRoot } from 'react-dom/client' 3 | 4 | export function hydrateIslands(islands, Providers = ({ children }) => children) { 5 | document.querySelectorAll('[data-tropical-hydration-component]').forEach((island) => { 6 | const Component = islands[island.dataset.tropicalHydrationComponent] 7 | 8 | if (!Component) { 9 | console.warn( 10 | `Found a server-rendered Tropical Island for ${island.dataset.tropicalHydrationComponent} but that component was not passed to hydrateIslands` 11 | ) 12 | return 13 | } 14 | 15 | const hydrationProps = JSON.parse(island.dataset.tropicalHydrationProps) 16 | hydrateRoot( 17 | island, 18 | 19 | 20 | 21 | ) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /packages/tropical-islands/src/index.js: -------------------------------------------------------------------------------- 1 | export * from './client' 2 | export * from './server' 3 | -------------------------------------------------------------------------------- /packages/tropical-islands/src/server.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export function Island({ 4 | children, 5 | componentName, 6 | islandTag: IslandComponent = 'div', 7 | ...islandProps 8 | }) { 9 | const hydrationProps = JSON.stringify(React.Children.only(children).props) 10 | return ( 11 | 16 | {children} 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /packages/tropical-scaffold/README.md: -------------------------------------------------------------------------------- 1 | # `tropical-scaffold` 2 | 3 | CLI for quickly scaffolding [Tropical](https://tropical.js.org) pages and components. 4 | 5 | Probably not very useful if you're not working on a Tropical project as it depends on this project structure: 6 | 7 | ``` 8 | src/ 9 | pages/ 10 | components/ 11 | package.json 12 | ``` 13 | 14 | ## Usage 15 | 16 | ```bash 17 | tropical-scaffold --type=page path/to/my-new-page 18 | tropical-scaffold --type=component MyNewComponent 19 | ``` 20 | -------------------------------------------------------------------------------- /packages/tropical-scaffold/bin/tropical-scaffold.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import {extname, basename, dirname, resolve} from 'path' 4 | import {packageDirectory} from 'pkg-dir' 5 | import fse from 'fs-extra' 6 | import {pascalCase, sentenceCase} from 'change-case' 7 | import minimist from 'minimist' 8 | 9 | const argv = minimist(process.argv.slice(2)) 10 | 11 | ;(async () => { 12 | const projectRoot = await packageDirectory() 13 | const name = argv._[0] 14 | 15 | switch (argv.type) { 16 | case 'page': 17 | scaffoldPage(projectRoot, name) 18 | break 19 | case 'component': 20 | scaffoldComponent(projectRoot, name) 21 | break 22 | default: 23 | throw new Error(`Unsupported --type: ${type}`) 24 | } 25 | })() 26 | 27 | async function write (outFile, contents) { 28 | try { 29 | const exists = await fse.pathExists(outFile) 30 | if (exists) { 31 | console.warn(`⚠️ File already exists, skipping: ${outFile}`) 32 | } else { 33 | await fse.outputFile(outFile, contents) 34 | console.log('🖨 Created:', outFile) 35 | } 36 | } catch (e) { 37 | console.error(e) 38 | } 39 | } 40 | 41 | async function scaffoldPage(projectRoot, path) { 42 | const dir = dirname(path) 43 | const ext = extname(path).toLowerCase() || '.mdx' 44 | const filename = basename(path, ext) 45 | 46 | let contents 47 | switch (ext) { 48 | case '.mdx': 49 | contents = mdxPage(sentenceCase(filename)) 50 | break 51 | case '.jsx': 52 | contents = jsxPage(pascalCase(filename), sentenceCase(filename)) 53 | break 54 | default: 55 | throw new Error(`Unsupported file extension: ${ext}`) 56 | } 57 | 58 | const outFile = resolve(projectRoot, 'src/pages', dir, `${filename}${ext}`) 59 | write(outFile, contents) 60 | } 61 | 62 | function mdxPage(title) { 63 | return `export const meta = { 64 | title: \`${title}\` 65 | } 66 | 67 | # ${title} 68 | ` 69 | } 70 | 71 | function jsxPage(componentName, title) { 72 | return `import { useFela } from 'react-fela' 73 | 74 | export const meta = { 75 | title: \`${title}\` 76 | } 77 | 78 | export default function ${componentName}Page ({ meta, pages }) { 79 | const { css } = useFela() 80 | 81 | return ( 82 | <> 83 |

{meta.title}

84 | 85 | ) 86 | } 87 | ` 88 | } 89 | 90 | async function scaffoldComponent(projectRoot, name) { 91 | const componentsDir = resolve(projectRoot, 'src/components') 92 | write(resolve(projectRoot, 'src/components', name, 'index.js'), componentIndex(name)) 93 | write(resolve(projectRoot, 'src/components', name, `${name}.jsx`), componentMain(name)) 94 | write(resolve(projectRoot, 'src/components', name, `${name}.stories.jsx`), componentStories(name)) 95 | } 96 | 97 | function componentIndex (componentName) { 98 | return `export * from './${componentName}'` 99 | } 100 | 101 | function componentMain (componentName) { 102 | return `import { useFela } from 'react-fela' 103 | 104 | export function ${componentName}() { 105 | const { css } = useFela() 106 | 107 | return ( 108 |
109 | ) 110 | } 111 | ` 112 | } 113 | 114 | function componentStories (componentName) { 115 | return `import { ${componentName} } from './${componentName}' 116 | 117 | export default { title: '${componentName}' } 118 | 119 | export const Basic = () => <${componentName} /> 120 | ` 121 | } 122 | -------------------------------------------------------------------------------- /packages/tropical-scaffold/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tropical-scaffold", 3 | "version": "2.0.0", 4 | "description": "CLI for scaffolding Tropical pages and components", 5 | "keywords": [ 6 | "tropical", 7 | "react" 8 | ], 9 | "author": "Ben Smithett ", 10 | "homepage": "https://github.com/bensmithett/tropical-utils", 11 | "license": "MIT", 12 | "main": "bin/tropical-scaffold.js", 13 | "bin": { 14 | "tropical-scaffold": "./bin/tropical-scaffold.js" 15 | }, 16 | "directories": { 17 | "bin": "bin" 18 | }, 19 | "files": [ 20 | "bin" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/bensmithett/tropical-utils.git" 25 | }, 26 | "type": "module", 27 | "bugs": { 28 | "url": "https://github.com/bensmithett/tropical-utils/issues" 29 | }, 30 | "dependencies": { 31 | "change-case": "^4.1.2", 32 | "fs-extra": "^10.0.0", 33 | "minimist": "^1.2.5", 34 | "pkg-dir": "^6.0.1" 35 | } 36 | } 37 | --------------------------------------------------------------------------------