├── app ├── styles │ └── tailwind │ │ ├── route.css │ │ └── base.css ├── utils.ts ├── entry.client.tsx ├── routes │ ├── index.tsx │ ├── marketing │ │ ├── index.tsx │ │ ├── banners.tsx │ │ ├── feature-sections.tsx │ │ ├── hero-sections.tsx │ │ └── headers.tsx │ ├── ecommerce │ │ ├── index.tsx │ │ ├── product-lists.tsx │ │ ├── category-previews.tsx │ │ ├── product-features.tsx │ │ └── product-quickviews.tsx │ ├── application │ │ ├── index.tsx │ │ ├── pagination.tsx │ │ ├── login-and-registration.tsx │ │ ├── description-lists.tsx │ │ └── dropdowns.tsx │ ├── marketing.tsx │ ├── application.tsx │ └── ecommerce.tsx ├── entry.server.tsx ├── components │ └── sidebar.tsx └── root.tsx ├── public └── favicon.ico ├── .prettierignore ├── remix.env.d.ts ├── vercel.json ├── docs └── styles-logic-statechart.png ├── .gitignore ├── server.js ├── tailwind.config.js ├── .eslintrc.js ├── remix.config.js ├── tsconfig.json ├── README.md ├── package.json └── scripts └── styles.js /app/styles/tailwind/route.css: -------------------------------------------------------------------------------- 1 | @tailwind components; 2 | @tailwind utilities; 3 | -------------------------------------------------------------------------------- /app/styles/tailwind/base.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brookslybrand/purge-per-route/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /app/styles/tailwind.css 8 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "env": { 4 | "ENABLE_FILE_SYSTEM_API": "1" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /docs/styles-logic-statechart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brookslybrand/purge-per-route/HEAD/docs/styles-logic-statechart.png -------------------------------------------------------------------------------- /app/utils.ts: -------------------------------------------------------------------------------- 1 | export function classNames(...classes: (string | undefined)[]) { 2 | return classes.filter(Boolean).join(' '); 3 | } 4 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from '@remix-run/react'; 2 | import { hydrate } from 'react-dom'; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .cache 4 | .env 5 | .vercel 6 | .output 7 | 8 | /build/ 9 | /public/build 10 | /api 11 | 12 | /app/styles/root.css 13 | /app/styles/routes 14 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from '@remix-run/vercel'; 2 | import * as build from '@remix-run/dev/server-build'; 3 | 4 | export default createRequestHandler({ build, mode: process.env.NODE_ENV }); 5 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from '@remix-run/server-runtime'; 2 | import type { LoaderFunction } from '@remix-run/server-runtime'; 3 | 4 | export const loader: LoaderFunction = () => { 5 | return redirect('/application'); 6 | }; 7 | -------------------------------------------------------------------------------- /app/routes/marketing/index.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from '@remix-run/node'; 2 | import type { LoaderFunction } from '@remix-run/node'; 3 | 4 | export let loader: LoaderFunction = ({ request }) => { 5 | return redirect(`${request.url}/hero-sections`); 6 | }; 7 | -------------------------------------------------------------------------------- /app/routes/ecommerce/index.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from '@remix-run/node'; 2 | import type { LoaderFunction } from '@remix-run/node'; 3 | 4 | export let loader: LoaderFunction = ({ request }) => { 5 | return redirect(`${request.url}/category-previews`); 6 | }; 7 | -------------------------------------------------------------------------------- /app/routes/application/index.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from '@remix-run/node'; 2 | import type { LoaderFunction } from '@remix-run/node'; 3 | 4 | export let loader: LoaderFunction = ({ request }) => { 5 | return redirect(`${request.url}/description-lists`); 6 | }; 7 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme'); 2 | 3 | module.exports = { 4 | content: ['./app/**/*.{ts,tsx,jsx,js}'], 5 | theme: { 6 | extend: { 7 | fontFamily: { 8 | sans: ['Inter var', ...defaultTheme.fontFamily.sans], 9 | }, 10 | }, 11 | }, 12 | plugins: [ 13 | require('@tailwindcss/forms'), 14 | require('@tailwindcss/aspect-ratio'), 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').Linter.BaseConfig} 3 | */ 4 | module.exports = { 5 | extends: [ 6 | '@remix-run/eslint-config', 7 | '@remix-run/eslint-config/node', 8 | '@remix-run/eslint-config/jest-testing-library', 9 | 'prettier', 10 | ], 11 | // we're using vitest which has a very similar API to jest 12 | // (so the linting plugins work nicely), but it we have to explicitly 13 | // set the jest version. 14 | settings: { 15 | jest: { 16 | version: 27, 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /app/routes/marketing.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from '@remix-run/react'; 2 | import Sidebar from '~/components/sidebar'; 3 | import type { LinksFunction } from '@remix-run/node'; 4 | import marketingCss from '~/styles/routes/marketing.css'; 5 | 6 | export let links: LinksFunction = () => [ 7 | { rel: 'stylesheet', href: marketingCss }, 8 | ]; 9 | 10 | export default function Marketing() { 11 | return ( 12 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev').AppConfig} 3 | */ 4 | module.exports = { 5 | serverBuildTarget: 'vercel', 6 | // When running locally in development mode, we use the built in remix 7 | // server. This does not understand the vercel lambda module format, 8 | // so we default back to the standard build output. 9 | server: process.env.NODE_ENV === 'development' ? undefined : './server.js', 10 | ignoredRouteFiles: ['**/.*'], 11 | // appDirectory: 'app', 12 | // assetsBuildDirectory: 'public/build', 13 | // serverBuildPath: 'api/index.js', 14 | // publicPath: '/build/', 15 | }; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "module": "CommonJS", 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "target": "ES2019", 12 | "strict": true, 13 | "baseUrl": ".", 14 | "paths": { 15 | "~/*": ["./app/*"] 16 | }, 17 | "skipLibCheck": true, 18 | "noEmit": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "allowJs": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from '@remix-run/node'; 2 | import { RemixServer } from '@remix-run/react'; 3 | import { renderToString } from 'react-dom/server'; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let markup = renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set('Content-Type', 'text/html'); 16 | 17 | return new Response('' + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /app/routes/application.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from '@remix-run/react'; 2 | import Sidebar from '~/components/sidebar'; 3 | import type { LinksFunction } from '@remix-run/node'; 4 | import applicationCss from '~/styles/routes/application.css'; 5 | 6 | export let links: LinksFunction = () => [ 7 | { rel: 'stylesheet', href: applicationCss }, 8 | ]; 9 | 10 | export default function Application() { 11 | return ( 12 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/routes/ecommerce.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from '@remix-run/react'; 2 | import Sidebar from '~/components/sidebar'; 3 | import type { LinksFunction } from '@remix-run/node'; 4 | import ecommerceCss from '~/styles/routes/ecommerce.css'; 5 | 6 | export let links: LinksFunction = () => [ 7 | { rel: 'stylesheet', href: ecommerceCss }, 8 | ]; 9 | 10 | export default function Marketing() { 11 | return ( 12 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/components/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLocation } from '@remix-run/react'; 2 | import { classNames } from '~/utils'; 3 | 4 | export interface SidebarProps { 5 | className?: string; 6 | subPages: string[]; 7 | children: React.ReactNode; 8 | } 9 | 10 | export default function Sidebar({ 11 | className, 12 | subPages, 13 | children, 14 | }: SidebarProps) { 15 | const { pathname } = useLocation(); 16 | return ( 17 |
18 |
19 |
20 |
21 |

22 | Products 23 |

24 | 25 |
26 |
28 |

Categories

29 |
    30 | {subPages.map((to) => ( 31 |
  • 32 | 42 | {to.replace(/-/g, ' ')} 43 | 44 |
  • 45 | ))} 46 |
47 |
48 |
{children}
49 |
50 |
51 |
52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tailwind Purge-Per-Route Demo 2 | 3 | This is a demo for creating tailwind stylesheets for routes that purge based on the entire ancestry of the route. 4 | 5 | Feel free to play with the [live demo](https://purge-per-route.vercel.app). To see the CSS splitting per route, open up your Network tab and hover/click on all the links! 6 | 7 | Here is a video that explains what's going on and why it's so cool! 8 | 9 | [![Purge-Per-Route Demo](https://img.youtube.com/vi/hGtdtDVUxIg/0.jpg)](https://www.youtube.com/watch?v=hGtdtDVUxIg) 10 | 11 | ## How does it work 12 | 13 | All of the magic is in [scripts/styles.js](./scripts/styles.js) 14 | 15 | I tried to make the logic as clean as possible, but it's still a solid 500 lines and was built for a demo, so it might not be the most production ready code you've seen. 16 | 17 | To help orient you around how the logic roughly works, here it is [represented as a statechart](https://stately.ai/registry/editor/105a41c2-1cd9-41a9-a27a-324c71bfb735) (note: the actually logic is not an explicit statechart). 18 | 19 | ![Statechart for generating stylesheets for a Remix app](docs/styles-logic-statechart.png) 20 | 21 | ## Getting started 22 | 23 | - Install dependencies: 24 | 25 | ```sh 26 | npm run install 27 | ``` 28 | 29 | - Start dev server: 30 | 31 | ```sh 32 | npm run dev 33 | ``` 34 | 35 | And you should be good to go! 36 | 37 | ## How this project was developed 38 | 39 | This project was started with the [Indie Stack](https://github.com/remix-run/indie-stack). 40 | 41 | A lot of the bits have been removed, such as ƒly, prisma, and mocking and testing 42 | 43 | To get started with the Indie stack on a separate project, simply run: 44 | 45 | ``` 46 | npx create-remix --template remix-run/indie-stack 47 | ``` 48 | 49 | Additionally, this demo leverages lots of free sample components/views from Tailwind UI: https://tailwindui.com/ 50 | 51 | Tailwind UI is a very cool paid product from Tailwind Labs (the company behind Tailwind CSS). Using Tailwind UI was the quickest way to add a bunch of tailwind classes to demonstrate the power behind the idea in this repository. 52 | -------------------------------------------------------------------------------- /app/routes/marketing/banners.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-is-valid */ 2 | import { SpeakerphoneIcon, XIcon } from '@heroicons/react/outline'; 3 | import type { MetaFunction, LinksFunction } from '@remix-run/node'; 4 | 5 | import bannersCss from '~/styles/routes/marketing/banners.css'; 6 | 7 | export let meta: MetaFunction = () => { 8 | return { title: 'Marketing | Banners' }; 9 | }; 10 | 11 | export const links: LinksFunction = () => { 12 | return [{ rel: 'stylesheet', href: bannersCss }]; 13 | }; 14 | 15 | export default function Banners() { 16 | return ( 17 |
18 |
19 |
20 |
21 | 22 | 27 |

28 | We announced a new product! 29 | 30 | Big news! We're excited to announce a brand new product. 31 | 32 |

33 |
34 | 42 |
43 | 50 |
51 |
52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "purge-per-route-cea0", 3 | "private": true, 4 | "description": "", 5 | "license": "", 6 | "sideEffects": false, 7 | "scripts": { 8 | "postinstall": "remix setup node", 9 | "build": "run-s build:*", 10 | "build:css": "cross-env NODE_ENV=production node scripts/styles.js", 11 | "build:remix": "remix build", 12 | "dev": "run-p dev:*", 13 | "dev:css": "cross-env NODE_ENV=development node scripts/styles.js", 14 | "dev:remix": "cross-env NODE_ENV=development binode -- @remix-run/dev:remix dev", 15 | "start": "cross-env NODE_ENV=development npm run build && remix-serve api/", 16 | "format": "prettier --write .", 17 | "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", 18 | "typecheck": "tsc -b", 19 | "validate": "run-p lint typecheck" 20 | }, 21 | "prettier": { 22 | "singleQuote": true 23 | }, 24 | "eslintIgnore": [ 25 | "/node_modules", 26 | "/public/build", 27 | "/api/_build" 28 | ], 29 | "dependencies": { 30 | "@headlessui/react": "^1.5.0", 31 | "@heroicons/react": "^1.0.6", 32 | "@remix-run/node": "^1.4.1", 33 | "@remix-run/react": "^1.4.1", 34 | "@remix-run/serve": "^1.4.1", 35 | "@remix-run/server-runtime": "^1.4.1", 36 | "@remix-run/vercel": "^1.4.3", 37 | "@tailwindcss/aspect-ratio": "^0.4.0", 38 | "@tailwindcss/forms": "^0.5.0", 39 | "@vercel/node": "^1.15.2", 40 | "react": "^17.0.2", 41 | "react-dom": "^17.0.2" 42 | }, 43 | "devDependencies": { 44 | "@remix-run/dev": "^1.4.1", 45 | "@remix-run/eslint-config": "^1.4.1", 46 | "@testing-library/dom": "^8.12.0", 47 | "@testing-library/react": "^12.1.4", 48 | "@testing-library/user-event": "^14.0.4", 49 | "@types/eslint": "^8.4.1", 50 | "@types/node": "^17.0.23", 51 | "@types/react": "^17.0.43", 52 | "@types/react-dom": "^17.0.14", 53 | "autoprefixer": "^10.4.4", 54 | "binode": "^1.0.5", 55 | "chokidar": "^3.5.3", 56 | "cross-env": "^7.0.3", 57 | "css-tree": "^2.1.0", 58 | "eslint": "^8.12.0", 59 | "eslint-config-prettier": "^8.5.0", 60 | "npm-run-all": "^4.1.5", 61 | "postcss": "^8.4.12", 62 | "prettier": "2.6.1", 63 | "prettier-plugin-tailwindcss": "^0.1.8", 64 | "purgecss": "^4.1.3", 65 | "tailwindcss": "^3.0.23", 66 | "ts-node": "^10.7.0", 67 | "typescript": "^4.6.3" 68 | }, 69 | "engines": { 70 | "node": ">=14" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/routes/ecommerce/product-lists.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction, LinksFunction } from '@remix-run/node'; 2 | 3 | import productListsCss from '~/styles/routes/ecommerce/product-lists.css'; 4 | 5 | export let meta: MetaFunction = () => { 6 | return { title: 'Ecommerce | Product Lists' }; 7 | }; 8 | 9 | export const links: LinksFunction = () => { 10 | return [{ rel: 'stylesheet', href: productListsCss }]; 11 | }; 12 | 13 | const products = [ 14 | { 15 | id: 1, 16 | name: 'Earthen Bottle', 17 | href: '#', 18 | price: '$48', 19 | imageSrc: 20 | 'https://tailwindui.com/img/ecommerce-images/category-page-04-image-card-01.jpg', 21 | imageAlt: 22 | 'Tall slender porcelain bottle with natural clay textured body and cork stopper.', 23 | }, 24 | { 25 | id: 2, 26 | name: 'Nomad Tumbler', 27 | href: '#', 28 | price: '$35', 29 | imageSrc: 30 | 'https://tailwindui.com/img/ecommerce-images/category-page-04-image-card-02.jpg', 31 | imageAlt: 32 | 'Olive drab green insulated bottle with flared screw lid and flat top.', 33 | }, 34 | { 35 | id: 3, 36 | name: 'Focus Paper Refill', 37 | href: '#', 38 | price: '$89', 39 | imageSrc: 40 | 'https://tailwindui.com/img/ecommerce-images/category-page-04-image-card-03.jpg', 41 | imageAlt: 42 | 'Person using a pen to cross a task off a productivity paper card.', 43 | }, 44 | { 45 | id: 4, 46 | name: 'Machined Mechanical Pencil', 47 | href: '#', 48 | price: '$35', 49 | imageSrc: 50 | 'https://tailwindui.com/img/ecommerce-images/category-page-04-image-card-04.jpg', 51 | imageAlt: 52 | 'Hand holding black machined steel mechanical pencil with brass tip and top.', 53 | }, 54 | // More products... 55 | ]; 56 | 57 | export default function ProductLists() { 58 | return ( 59 |
60 |
61 |

Products

62 | 63 |
64 | {products.map((product) => ( 65 | 66 |
67 | {product.imageAlt} 72 |
73 |

{product.name}

74 |

75 | {product.price} 76 |

77 |
78 | ))} 79 |
80 |
81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /app/routes/ecommerce/category-previews.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction, LinksFunction } from '@remix-run/node'; 2 | 3 | import categoryPreviewsCss from '~/styles/routes/ecommerce/category-previews.css'; 4 | 5 | export let meta: MetaFunction = () => { 6 | return { title: 'Ecommerce | Category Previews' }; 7 | }; 8 | 9 | export const links: LinksFunction = () => { 10 | return [{ rel: 'stylesheet', href: categoryPreviewsCss }]; 11 | }; 12 | 13 | const callouts = [ 14 | { 15 | name: 'Desk and Office', 16 | description: 'Work from home accessories', 17 | imageSrc: 18 | 'https://tailwindui.com/img/ecommerce-images/home-page-02-edition-01.jpg', 19 | imageAlt: 20 | 'Desk with leather desk pad, walnut desk organizer, wireless keyboard and mouse, and porcelain mug.', 21 | href: '#', 22 | }, 23 | { 24 | name: 'Self-Improvement', 25 | description: 'Journals and note-taking', 26 | imageSrc: 27 | 'https://tailwindui.com/img/ecommerce-images/home-page-02-edition-02.jpg', 28 | imageAlt: 29 | 'Wood table with porcelain mug, leather journal, brass pen, leather key ring, and a houseplant.', 30 | href: '#', 31 | }, 32 | { 33 | name: 'Travel', 34 | description: 'Daily commute essentials', 35 | imageSrc: 36 | 'https://tailwindui.com/img/ecommerce-images/home-page-02-edition-03.jpg', 37 | imageAlt: 'Collection of four insulated travel bottles on wooden shelf.', 38 | href: '#', 39 | }, 40 | ]; 41 | 42 | export default function CategoryPreviews() { 43 | return ( 44 |
45 |
46 |
47 |

Collections

48 | 49 |
50 | {callouts.map((callout) => ( 51 |
52 |
53 | {callout.imageAlt} 58 |
59 |

60 | 61 | 62 | {callout.name} 63 | 64 |

65 |

66 | {callout.description} 67 |

68 |
69 | ))} 70 |
71 |
72 |
73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /app/routes/marketing/feature-sections.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AnnotationIcon, 3 | GlobeAltIcon, 4 | LightningBoltIcon, 5 | ScaleIcon, 6 | } from '@heroicons/react/outline'; 7 | import type { MetaFunction, LinksFunction } from '@remix-run/node'; 8 | 9 | import featureSections from '~/styles/routes/marketing/feature-sections.css'; 10 | 11 | export let meta: MetaFunction = () => { 12 | return { title: 'Marketing | Feature Sections' }; 13 | }; 14 | 15 | export const links: LinksFunction = () => { 16 | return [{ rel: 'stylesheet', href: featureSections }]; 17 | }; 18 | 19 | const features = [ 20 | { 21 | name: 'Competitive exchange rates', 22 | description: 23 | 'Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.', 24 | icon: GlobeAltIcon, 25 | }, 26 | { 27 | name: 'No hidden fees', 28 | description: 29 | 'Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.', 30 | icon: ScaleIcon, 31 | }, 32 | { 33 | name: 'Transfers are instant', 34 | description: 35 | 'Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.', 36 | icon: LightningBoltIcon, 37 | }, 38 | { 39 | name: 'Mobile notifications', 40 | description: 41 | 'Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.', 42 | icon: AnnotationIcon, 43 | }, 44 | ]; 45 | 46 | export default function FeatureSections() { 47 | return ( 48 |
49 |
50 |
51 |

52 | Transactions 53 |

54 |

55 | A better way to send money 56 |

57 |

58 | Lorem ipsum dolor sit amet consect adipisicing elit. Possimus magnam 59 | voluptatum cupiditate veritatis in accusamus quisquam. 60 |

61 |
62 | 63 |
64 |
65 | {features.map((feature) => ( 66 |
67 |
68 |
69 |
71 |

72 | {feature.name} 73 |

74 |
75 |
76 | {feature.description} 77 |
78 |
79 | ))} 80 |
81 |
82 |
83 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /app/routes/ecommerce/product-features.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction, LinksFunction } from '@remix-run/node'; 2 | 3 | import productFeaturesCss from '~/styles/routes/ecommerce/product-features.css'; 4 | 5 | export let meta: MetaFunction = () => { 6 | return { title: 'Ecommerce | Product Features' }; 7 | }; 8 | 9 | export const links: LinksFunction = () => { 10 | return [{ rel: 'stylesheet', href: productFeaturesCss }]; 11 | }; 12 | 13 | /* This example requires Tailwind CSS v2.0+ */ 14 | const features = [ 15 | { name: 'Origin', description: 'Designed by Good Goods, Inc.' }, 16 | { 17 | name: 'Material', 18 | description: 19 | 'Solid walnut base with rare earth magnets and powder coated steel card cover', 20 | }, 21 | { name: 'Dimensions', description: '6.25" x 3.55" x 1.15"' }, 22 | { name: 'Finish', description: 'Hand sanded and finished with natural oil' }, 23 | { name: 'Includes', description: 'Wood card tray and 3 refill packs' }, 24 | { 25 | name: 'Considerations', 26 | description: 27 | 'Made from natural materials. Grain and color vary with each item.', 28 | }, 29 | ]; 30 | 31 | export default function ProductFeatures() { 32 | return ( 33 |
34 |
35 |
36 |

37 | Technical Specifications 38 |

39 |

40 | The walnut wood card tray is precision milled to perfectly fit a 41 | stack of Focus cards. The powder coated steel divider separates 42 | active cards from new ones, or can be used to archive important task 43 | lists. 44 |

45 | 46 |
47 | {features.map((feature) => ( 48 |
49 |
{feature.name}
50 |
51 | {feature.description} 52 |
53 |
54 | ))} 55 |
56 |
57 |
58 | Walnut card tray with white powder coated steel divider and 3 punchout holes. 63 | Top down view of walnut card tray with embedded magnets and card groove. 68 | Side of walnut card tray with card groove and recessed card area. 73 | Walnut card tray filled with cards and card angled in dedicated groove. 78 |
79 |
80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /app/routes/application/pagination.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-is-valid */ 2 | import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'; 3 | import type { MetaFunction, LinksFunction } from '@remix-run/node'; 4 | 5 | import paginationCss from '~/styles/routes/application/pagination.css'; 6 | 7 | export let meta: MetaFunction = () => { 8 | return { title: 'Application | Pagination' }; 9 | }; 10 | 11 | export const links: LinksFunction = () => { 12 | return [{ rel: 'stylesheet', href: paginationCss }]; 13 | }; 14 | 15 | export default function Pagination() { 16 | return ( 17 |
18 | 32 |
33 |
34 |

35 | Showing 1 to{' '} 36 | 10 of{' '} 37 | 97 results 38 |

39 |
40 |
41 | 101 |
102 |
103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /app/routes/application/login-and-registration.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-is-valid */ 2 | import { LockClosedIcon } from '@heroicons/react/solid'; 3 | import type { MetaFunction, LinksFunction } from '@remix-run/node'; 4 | import loginAndRegistrationCss from '~/styles/routes/application/login-and-registration.css'; 5 | 6 | export let meta: MetaFunction = () => { 7 | return { title: 'Application | Login and Registration' }; 8 | }; 9 | 10 | export const links: LinksFunction = () => { 11 | return [{ rel: 'stylesheet', href: loginAndRegistrationCss }]; 12 | }; 13 | 14 | export default function LoginAndRegistration() { 15 | return ( 16 | <> 17 |
18 |
19 |
20 | Workflow 25 |

26 | Sign in to your account 27 |

28 |

29 | Or{' '} 30 | 34 | start your 14-day free trial 35 | 36 |

37 |
38 |
39 | 40 |
41 |
42 | 45 | 54 |
55 |
56 | 59 | 68 |
69 |
70 | 71 |
72 |
73 | 79 | 85 |
86 | 87 | 95 |
96 | 97 |
98 | 110 |
111 |
112 |
113 |
114 | 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /app/routes/application/description-lists.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-is-valid */ 2 | import { PaperClipIcon } from '@heroicons/react/solid'; 3 | import type { MetaFunction, LinksFunction } from '@remix-run/node'; 4 | 5 | import descriptionListsCss from '~/styles/routes/application/description-lists.css'; 6 | 7 | export let meta: MetaFunction = () => { 8 | return { title: 'Application | Description Lists' }; 9 | }; 10 | 11 | export const links: LinksFunction = () => { 12 | return [{ rel: 'stylesheet', href: descriptionListsCss }]; 13 | }; 14 | 15 | export default function DescriptionLists() { 16 | return ( 17 |
18 |
19 |

20 | Applicant Information 21 |

22 |

23 | Personal details and application. 24 |

25 |
26 |
27 |
28 |
29 |
Full name
30 |
31 | Margot Foster 32 |
33 |
34 |
35 |
36 | Application for 37 |
38 |
39 | Backend Developer 40 |
41 |
42 |
43 |
Email address
44 |
45 | margotfoster@example.com 46 |
47 |
48 |
49 |
50 | Salary expectation 51 |
52 |
53 | $120,000 54 |
55 |
56 |
57 |
About
58 |
59 | Fugiat ipsum ipsum deserunt culpa aute sint do nostrud anim 60 | incididunt cillum culpa consequat. Excepteur qui ipsum aliquip 61 | consequat sint. Sit id mollit nulla mollit nostrud in ea officia 62 | proident. Irure nostrud pariatur mollit ad adipisicing 63 | reprehenderit deserunt qui eu. 64 |
65 |
66 |
67 |
Attachments
68 |
69 |
    70 |
  • 71 |
    72 |
    80 | 88 |
  • 89 |
  • 90 |
    91 |
    99 | 107 |
  • 108 |
109 |
110 |
111 |
112 |
113 |
114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /app/routes/marketing/hero-sections.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-is-valid */ 2 | import { Fragment } from 'react'; 3 | import { Popover, Transition } from '@headlessui/react'; 4 | import { MenuIcon, XIcon } from '@heroicons/react/outline'; 5 | import type { MetaFunction, LinksFunction } from '@remix-run/node'; 6 | 7 | import hearoSectionsCss from '~/styles/routes/marketing/hero-sections.css'; 8 | 9 | export let meta: MetaFunction = () => { 10 | return { title: 'Marketing | Hero Sections' }; 11 | }; 12 | 13 | export const links: LinksFunction = () => { 14 | return [{ rel: 'stylesheet', href: hearoSectionsCss }]; 15 | }; 16 | 17 | const navigation = [ 18 | { name: 'Product', href: '#' }, 19 | { name: 'Features', href: '#' }, 20 | { name: 'Marketplace', href: '#' }, 21 | { name: 'Company', href: '#' }, 22 | ]; 23 | 24 | export default function HeroSections() { 25 | return ( 26 |
27 |
28 |
29 | 38 | 39 | 40 |
41 | 81 |
82 | 83 | 92 | 96 |
97 |
98 |
99 | 104 |
105 |
106 | 107 | Close main menu 108 | 110 |
111 |
112 |
113 | {navigation.map((item) => ( 114 | 119 | {item.name} 120 | 121 | ))} 122 |
123 | 127 | Log in 128 | 129 |
130 |
131 |
132 |
133 | 134 |
135 |
136 |

137 | Data to enrich your{' '} 138 | 139 | online business 140 | 141 |

142 |

143 | Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui 144 | lorem cupidatat commodo. Elit sunt amet fugiat veniam occaecat 145 | fugiat aliqua. 146 |

147 |
148 | 156 | 164 |
165 |
166 |
167 |
168 |
169 |
170 | 175 |
176 |
177 | ); 178 | } 179 | -------------------------------------------------------------------------------- /app/routes/application/dropdowns.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-is-valid */ 2 | import { Fragment } from 'react'; 3 | import { Menu, Transition } from '@headlessui/react'; 4 | import { ChevronDownIcon } from '@heroicons/react/solid'; 5 | import type { MetaFunction, LinksFunction } from '@remix-run/node'; 6 | import { classNames } from '~/utils'; 7 | 8 | import dropdowns from '~/styles/routes/application/dropdowns.css'; 9 | 10 | export let meta: MetaFunction = () => { 11 | return { title: 'Application | Dropdowns' }; 12 | }; 13 | 14 | export const links: LinksFunction = () => { 15 | return [{ rel: 'stylesheet', href: dropdowns }]; 16 | }; 17 | 18 | export default function Dropdowns() { 19 | return ( 20 |
21 | 22 |
23 | 24 | Options 25 | 30 |
31 | 32 | 41 | 42 |
43 | 44 | {({ active }) => ( 45 | 52 | Account settings 53 | 54 | )} 55 | 56 | 57 | {({ active }) => ( 58 | 65 | Support 66 | 67 | )} 68 | 69 | 70 | {({ active }) => ( 71 | 78 | License 79 | 80 | )} 81 | 82 |
83 | 84 | {({ active }) => ( 85 | 94 | )} 95 | 96 |
97 |
98 |
99 |
100 |
101 | 102 | 103 |
104 | 105 | Options 106 | 111 |
112 | 113 | 122 | 123 |
124 | 125 | {({ active }) => ( 126 | 133 | Edit 134 | 135 | )} 136 | 137 | 138 | {({ active }) => ( 139 | 146 | Duplicate 147 | 148 | )} 149 | 150 |
151 |
152 | 153 | {({ active }) => ( 154 | 161 | Archive 162 | 163 | )} 164 | 165 | 166 | {({ active }) => ( 167 | 174 | Move 175 | 176 | )} 177 | 178 |
179 |
180 | 181 | {({ active }) => ( 182 | 189 | Share 190 | 191 | )} 192 | 193 | 194 | {({ active }) => ( 195 | 202 | Add to favorites 203 | 204 | )} 205 | 206 |
207 |
208 | 209 | {({ active }) => ( 210 | 217 | Delete 218 | 219 | )} 220 | 221 |
222 |
223 |
224 |
225 |
226 | ); 227 | } 228 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { LinksFunction, MetaFunction } from '@remix-run/node'; 2 | import { 3 | Link, 4 | Links, 5 | LiveReload, 6 | Meta, 7 | Outlet, 8 | Scripts, 9 | ScrollRestoration, 10 | useLocation, 11 | } from '@remix-run/react'; 12 | 13 | import { Fragment } from 'react'; 14 | import { Disclosure, Menu, Transition } from '@headlessui/react'; 15 | import { BellIcon, MenuIcon, XIcon } from '@heroicons/react/outline'; 16 | 17 | import tailwindStylesheetUrl from '~/styles/root.css'; 18 | import { classNames } from '~/utils'; 19 | 20 | export const links: LinksFunction = () => { 21 | return [ 22 | { rel: 'stylesheet', href: 'https://rsms.me/inter/inter.css' }, 23 | { rel: 'stylesheet', href: tailwindStylesheetUrl }, 24 | ]; 25 | }; 26 | 27 | export const meta: MetaFunction = () => ({ 28 | charset: 'utf-8', 29 | title: 'Purge Per Route', 30 | viewport: 'width=device-width,initial-scale=1', 31 | }); 32 | 33 | const user = { 34 | name: 'Tom Cook', 35 | email: 'tom@example.com', 36 | imageUrl: 37 | 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80', 38 | }; 39 | const userNavigation = [ 40 | { name: 'Your Profile', href: '#' }, 41 | { name: 'Settings', href: '#' }, 42 | { name: 'Sign out', href: '#' }, 43 | ]; 44 | 45 | export default function App() { 46 | const { pathname } = useLocation(); 47 | 48 | const navigation = ['application', 'marketing', 'ecommerce'].map((route) => { 49 | const href = `/${route}`; 50 | const regex = RegExp(route); 51 | return { 52 | name: route, 53 | href, 54 | current: regex.test(pathname), 55 | }; 56 | }); 57 | 58 | const title = `${navigation.find((current) => current)?.name} examples`; 59 | 60 | return ( 61 | 62 | 63 | 64 | 65 | 66 | 67 | <> 68 |
69 | 70 | {({ open }) => ( 71 | <> 72 |
73 |
74 |
75 |
76 | Workflow 81 |
82 |
83 |
84 | {navigation.map((item) => ( 85 | 96 | {item.name} 97 | 98 | ))} 99 |
100 |
101 |
102 |
103 |
104 | 111 | 112 | {/* Profile dropdown */} 113 | 114 |
115 | 116 | Open user menu 117 | 122 | 123 |
124 | 133 | 134 | {userNavigation.map((item) => ( 135 | 136 | {({ active }) => ( 137 | 144 | {item.name} 145 | 146 | )} 147 | 148 | ))} 149 | 150 | 151 |
152 |
153 |
154 |
155 | {/* Mobile menu button */} 156 | 157 | Open main menu 158 | {open ? ( 159 | 170 |
171 |
172 |
173 | 174 | 175 |
176 | {navigation.map((item) => ( 177 | 189 | {item.name} 190 | 191 | ))} 192 |
193 |
194 |
195 |
196 | 201 |
202 |
203 |
204 | {user.name} 205 |
206 |
207 | {user.email} 208 |
209 |
210 | 217 |
218 |
219 | {userNavigation.map((item) => ( 220 | 226 | {item.name} 227 | 228 | ))} 229 |
230 |
231 |
232 | 233 | )} 234 |
235 | 236 |
237 |
238 |

239 | {title} 240 |

241 |
242 |
243 |
244 |
245 | 246 |
247 |
248 |
249 | 250 | 251 | 252 | 253 | 254 | 255 | ); 256 | } 257 | -------------------------------------------------------------------------------- /app/routes/ecommerce/product-quickviews.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-is-valid */ 2 | import { Fragment, useState } from 'react'; 3 | import { Dialog, RadioGroup, Transition } from '@headlessui/react'; 4 | import { XIcon } from '@heroicons/react/outline'; 5 | import { StarIcon } from '@heroicons/react/solid'; 6 | import { classNames } from '~/utils'; 7 | import type { MetaFunction, LinksFunction } from '@remix-run/node'; 8 | 9 | import productQuickviews from '~/styles/routes/ecommerce/product-quickviews.css'; 10 | 11 | export let meta: MetaFunction = () => { 12 | return { title: 'Ecommerce | Product Quickviews' }; 13 | }; 14 | 15 | export const links: LinksFunction = () => { 16 | return [{ rel: 'stylesheet', href: productQuickviews }]; 17 | }; 18 | 19 | const product = { 20 | name: 'Basic Tee 6-Pack ', 21 | price: '$192', 22 | rating: 3.9, 23 | reviewCount: 117, 24 | href: '#', 25 | imageSrc: 26 | 'https://tailwindui.com/img/ecommerce-images/product-quick-preview-02-detail.jpg', 27 | imageAlt: 'Two each of gray, white, and black shirts arranged on table.', 28 | colors: [ 29 | { name: 'White', class: 'bg-white', selectedClass: 'ring-gray-400' }, 30 | { name: 'Gray', class: 'bg-gray-200', selectedClass: 'ring-gray-400' }, 31 | { name: 'Black', class: 'bg-gray-900', selectedClass: 'ring-gray-900' }, 32 | ], 33 | sizes: [ 34 | { name: 'XXS', inStock: true }, 35 | { name: 'XS', inStock: true }, 36 | { name: 'S', inStock: true }, 37 | { name: 'M', inStock: true }, 38 | { name: 'L', inStock: true }, 39 | { name: 'XL', inStock: true }, 40 | { name: 'XXL', inStock: true }, 41 | { name: 'XXXL', inStock: false }, 42 | ], 43 | }; 44 | 45 | export default function ProductQuickviews() { 46 | const [open, setOpen] = useState(true); 47 | const [selectedColor, setSelectedColor] = useState(product.colors[0]); 48 | const [selectedSize, setSelectedSize] = useState(product.sizes[2]); 49 | 50 | return ( 51 | 52 | 57 |
61 | 70 | 71 | 72 | 73 | {/* This element is to trick the browser into centering the modal contents. */} 74 | 80 | 89 |
90 |
91 | 99 | 100 |
101 |
102 | {product.imageAlt} 107 |
108 |
109 |

110 | {product.name} 111 |

112 | 113 |
117 |

118 | Product information 119 |

120 | 121 |

{product.price}

122 | 123 | {/* Reviews */} 124 |
125 |

Reviews

126 |
127 |
128 | {[0, 1, 2, 3, 4].map((rating) => ( 129 | rating 133 | ? 'text-gray-900' 134 | : 'text-gray-200', 135 | 'h-5 w-5 flex-shrink-0' 136 | )} 137 | aria-hidden="true" 138 | /> 139 | ))} 140 |
141 |

142 | {product.rating} out of 5 stars 143 |

144 | 148 | {product.reviewCount} reviews 149 | 150 |
151 |
152 |
153 | 154 |
158 |

159 | Product options 160 |

161 | 162 |
163 | {/* Colors */} 164 |
165 |

166 | Color 167 |

168 | 169 | 174 | 175 | Choose a color 176 | 177 |
178 | {product.colors.map((color) => ( 179 | 183 | classNames( 184 | color.selectedClass, 185 | active && checked 186 | ? 'ring ring-offset-1' 187 | : '', 188 | !active && checked ? 'ring-2' : '', 189 | 'relative -m-0.5 flex cursor-pointer items-center justify-center rounded-full p-0.5 focus:outline-none' 190 | ) 191 | } 192 | > 193 | 194 | {color.name} 195 | 196 | 204 | ))} 205 |
206 |
207 |
208 | 209 | {/* Sizes */} 210 |
211 |
212 |

213 | Size 214 |

215 | 219 | Size guide 220 | 221 |
222 | 223 | 228 | 229 | Choose a size 230 | 231 |
232 | {product.sizes.map((size) => ( 233 | 238 | classNames( 239 | size.inStock 240 | ? 'cursor-pointer bg-white text-gray-900 shadow-sm' 241 | : 'cursor-not-allowed bg-gray-50 text-gray-200', 242 | active ? 'ring-2 ring-indigo-500' : '', 243 | 'group relative flex items-center justify-center rounded-md border py-3 px-4 text-sm font-medium uppercase hover:bg-gray-50 focus:outline-none sm:flex-1' 244 | ) 245 | } 246 | > 247 | {({ active, checked }) => ( 248 | <> 249 | 250 | {size.name} 251 | 252 | {size.inStock ? ( 253 | 289 | 290 |
291 | 292 | 298 | 299 |
300 |
301 |
302 |
303 |
304 |
305 |
306 |
307 |
308 | ); 309 | } 310 | -------------------------------------------------------------------------------- /app/routes/marketing/headers.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-is-valid */ 2 | import { Fragment } from 'react'; 3 | import { Popover, Transition } from '@headlessui/react'; 4 | import { 5 | BookmarkAltIcon, 6 | CalendarIcon, 7 | ChartBarIcon, 8 | CursorClickIcon, 9 | MenuIcon, 10 | PhoneIcon, 11 | PlayIcon, 12 | RefreshIcon, 13 | ShieldCheckIcon, 14 | SupportIcon, 15 | ViewGridIcon, 16 | XIcon, 17 | } from '@heroicons/react/outline'; 18 | import { ChevronDownIcon } from '@heroicons/react/solid'; 19 | import type { MetaFunction, LinksFunction } from '@remix-run/node'; 20 | import { classNames } from '~/utils'; 21 | 22 | import headersCss from '~/styles/routes/marketing/headers.css'; 23 | 24 | export let meta: MetaFunction = () => { 25 | return { title: 'Marketing | Headers' }; 26 | }; 27 | 28 | export const links: LinksFunction = () => { 29 | return [{ rel: 'stylesheet', href: headersCss }]; 30 | }; 31 | 32 | const solutions = [ 33 | { 34 | name: 'Analytics', 35 | description: 36 | 'Get a better understanding of where your traffic is coming from.', 37 | href: '#', 38 | icon: ChartBarIcon, 39 | }, 40 | { 41 | name: 'Engagement', 42 | description: 'Speak directly to your customers in a more meaningful way.', 43 | href: '#', 44 | icon: CursorClickIcon, 45 | }, 46 | { 47 | name: 'Security', 48 | description: "Your customers' data will be safe and secure.", 49 | href: '#', 50 | icon: ShieldCheckIcon, 51 | }, 52 | { 53 | name: 'Integrations', 54 | description: "Connect with third-party tools that you're already using.", 55 | href: '#', 56 | icon: ViewGridIcon, 57 | }, 58 | { 59 | name: 'Automations', 60 | description: 61 | 'Build strategic funnels that will drive your customers to convert', 62 | href: '#', 63 | icon: RefreshIcon, 64 | }, 65 | ]; 66 | const callsToAction = [ 67 | { name: 'Watch Demo', href: '#', icon: PlayIcon }, 68 | { name: 'Contact Sales', href: '#', icon: PhoneIcon }, 69 | ]; 70 | const resources = [ 71 | { 72 | name: 'Help Center', 73 | description: 74 | 'Get all of your questions answered in our forums or contact support.', 75 | href: '#', 76 | icon: SupportIcon, 77 | }, 78 | { 79 | name: 'Guides', 80 | description: 81 | 'Learn how to maximize our platform to get the most out of it.', 82 | href: '#', 83 | icon: BookmarkAltIcon, 84 | }, 85 | { 86 | name: 'Events', 87 | description: 88 | 'See what meet-ups and other events we might be planning near you.', 89 | href: '#', 90 | icon: CalendarIcon, 91 | }, 92 | { 93 | name: 'Security', 94 | description: 'Understand how we take your privacy seriously.', 95 | href: '#', 96 | icon: ShieldCheckIcon, 97 | }, 98 | ]; 99 | const recentPosts = [ 100 | { id: 1, name: 'Boost your conversion rate', href: '#' }, 101 | { 102 | id: 2, 103 | name: 'How to use search engine optimization to drive traffic to your site', 104 | href: '#', 105 | }, 106 | { id: 3, name: 'Improve your customer experience', href: '#' }, 107 | ]; 108 | 109 | export default function Headers() { 110 | return ( 111 | 112 |
113 |
114 | 124 |
125 | 126 | Open menu 127 | 129 |
130 | 131 | 132 | {({ open }) => ( 133 | <> 134 | 140 | Solutions 141 | 149 | 150 | 159 | 160 |
161 | 183 |
184 | {callsToAction.map((item) => ( 185 | 197 | ))} 198 |
199 |
200 |
201 |
202 | 203 | )} 204 |
205 | 206 | 210 | Pricing 211 | 212 | 216 | Docs 217 | 218 | 219 | 220 | {({ open }) => ( 221 | <> 222 | 228 | More 229 | 237 | 238 | 247 | 248 |
249 | 271 |
272 |
273 |

274 | Recent Posts 275 |

276 | 291 |
292 | 302 |
303 |
304 |
305 |
306 | 307 | )} 308 |
309 |
310 | 324 |
325 |
326 | 327 | 336 | 340 |
341 |
342 |
343 |
344 | Workflow 349 |
350 |
351 | 352 | Close menu 353 | 355 |
356 |
357 |
358 | 375 |
376 |
377 |
378 |
379 | 383 | Pricing 384 | 385 | 386 | 390 | Docs 391 | 392 | {resources.map((item) => ( 393 | 398 | {item.name} 399 | 400 | ))} 401 |
402 |
403 | 407 | Sign up 408 | 409 |

410 | Existing customer?{' '} 411 | 412 | Sign in 413 | 414 |

415 |
416 |
417 |
418 |
419 |
420 |
421 | ); 422 | } 423 | -------------------------------------------------------------------------------- /scripts/styles.js: -------------------------------------------------------------------------------- 1 | let { writeFile, readdir, rm, mkdir } = require('fs/promises'); 2 | let path = require('path'); 3 | let { spawn } = require('child_process'); 4 | let csstree = require('css-tree'); 5 | let chokidar = require('chokidar'); 6 | const { performance } = require('perf_hooks'); 7 | 8 | let appPath = path.join(__dirname, '../app'); 9 | let routesPath = path.join(appPath, 'routes'); 10 | let stylesPath = path.join(appPath, 'styles'); 11 | let stylesRoutesPath = path.join(stylesPath, 'routes'); 12 | 13 | let baseTailwindCss = path.join(stylesPath, 'tailwind/base.css'); 14 | let routeTailwindCss = path.join(stylesPath, 'tailwind/route.css'); 15 | 16 | let root = path.join(appPath, 'root.{js,jsx,ts,tsx}'); 17 | let rootStylesPath = path.join(stylesPath, 'root.css'); 18 | 19 | createStyles(); 20 | 21 | // Main function to generate the stylesheets 22 | async function createStyles() { 23 | let isProd = process.env.NODE_ENV === 'production'; 24 | let t0 = performance.now(); 25 | // Dump all the files and start with a clean slate for production 26 | // This would be bad in development, lots of dev server crashing 😬 27 | if (isProd) { 28 | dumpCssFiles(); 29 | } 30 | 31 | // Start creating the root tailwind styles 32 | let rootAstPromise = generateAndSaveRootTailwindStyles(); 33 | // Generate all ASTs—we must wait until this process is done to proceed 34 | let routeAstMap = await generateRouteTailwindAstMap(); 35 | 36 | // Make all the directories we will need, as well as resolve and pull out the root AST 37 | let [rootAst] = await Promise.all([ 38 | rootAstPromise, 39 | makeDirectories(routeAstMap.keys()), 40 | ]); 41 | 42 | // Maybe should check if in Dev specifically, but for now this is fine 43 | if (!isProd) { 44 | setupWatcher(rootAst, routeAstMap); 45 | } 46 | 47 | // Create all of the route stylesheets 48 | await generateAndSaveRouteTailwindStyles(routeAstMap, rootAst.classNames); 49 | 50 | console.log(); 51 | if (isProd) { 52 | console.log( 53 | `All css has been successfully generated in ${millisecondsFromTimestamp( 54 | t0 55 | )}ms` 56 | ); 57 | } else { 58 | console.log( 59 | `Initially css generated in ${millisecondsFromTimestamp(t0)}ms` 60 | ); 61 | console.log('Watching for updates...'); 62 | } 63 | console.log(); 64 | } 65 | 66 | // #region Watcher logic for dev mode 67 | /** 68 | * Sets up a watcher to regenerate the stylesheets when appropriate files change 69 | * @param {{ast: csstree.CssNode; classNames: Set;} rootAst 70 | * @param {Map;}>} routeAstMap 71 | */ 72 | function setupWatcher(rootAst, routeAstMap) { 73 | let rootWatcher = chokidar.watch( 74 | // `${root},${appPath}/components/**/*.{js,jsx,ts,tsx}`, 75 | [root, `${appPath}/components/**/*.{js,jsx,ts,tsx}`], 76 | { 77 | persistent: true, 78 | ignoreInitial: true, 79 | } 80 | ); 81 | 82 | rootWatcher.on('all', () => 83 | logStyleUpdate('update', async () => { 84 | let newRootAst = await generateAndSaveRootTailwindStyles(); 85 | // Check if the styles have actually changed, otherwise we can bail 86 | let hasSameClassnames = areSetsEqual( 87 | rootAst.classNames, 88 | newRootAst.classNames 89 | ); 90 | if (hasSameClassnames) return; 91 | // Update the reference for the other watcher to use and regenerate all styles 92 | // since root is the ancestor of everything 93 | rootAst = newRootAst; 94 | await generateAndSaveRouteTailwindStyles(routeAstMap, rootAst.classNames); 95 | return rootStylesPath; 96 | }) 97 | ); 98 | 99 | // Not sure if we need to ignore any files since everything in `/routes/` should be a route 100 | let routesWatcher = chokidar.watch(routesPath, { 101 | persistent: true, 102 | ignoreInitial: true, 103 | }); 104 | 105 | // Setup a watcher to remove ASTs and files, create directories and add new ASTs, and update ASTs 106 | routesWatcher 107 | .on('unlink', (pathname) => 108 | logStyleUpdate('remove', async () => { 109 | // Remove AST from map and remove the css file 110 | routeAstMap.delete(pathname); 111 | rm(getCssPathname(pathname)); 112 | await generateAndSaveRouteTailwindStyles( 113 | routeAstMap, 114 | rootAst.classNames 115 | ); 116 | }) 117 | ) 118 | .on('add', (pathname) => { 119 | logStyleUpdate('add', async () => { 120 | // Generate the new entry and create the necessary directory 121 | // (doesn't matter if directory exists) 122 | let [entry] = await Promise.all([ 123 | generateRouteTailwindAstEntry(pathname), 124 | makeDirectories([pathname]), 125 | ]); 126 | routeAstMap.set(entry[0], entry[1]); 127 | await generateAndSaveRouteTailwindStyles( 128 | routeAstMap, 129 | rootAst.classNames 130 | ); 131 | return getCssPathname(entry[0]); 132 | }); 133 | }) 134 | .on('change', (pathname) => { 135 | logStyleUpdate('update', async () => { 136 | let [extensionlessPathname, astObject] = 137 | await generateRouteTailwindAstEntry(pathname); 138 | // Check if the styles have actually changed, otherwise we can bail 139 | let hasSameClassnames = areSetsEqual( 140 | routeAstMap.get(extensionlessPathname).classNames, 141 | astObject.classNames 142 | ); 143 | if (hasSameClassnames) return; 144 | // Update the AST map and save the styles 145 | routeAstMap.set(extensionlessPathname, astObject); 146 | await generateAndSaveRouteTailwindStyles( 147 | routeAstMap, 148 | rootAst.classNames, 149 | new Set([extensionlessPathname]) 150 | ); 151 | return getCssPathname(extensionlessPathname); 152 | }); 153 | // Generate the AST 154 | }) 155 | // Don't know if better error handling is needed 156 | .on('error', (error) => console.error(`Watcher error: ${error}`)); 157 | } 158 | 159 | /** 160 | * @param {'add' | 'update' | 'remove'} action 161 | * @param {() => Promise} cb 162 | */ 163 | async function logStyleUpdate(action, cb) { 164 | let t0 = performance.now(); 165 | let pathname = await cb(); 166 | if (pathname) { 167 | let displayAction = 168 | action === 'add' ? 'Added' : action === 'update' ? 'Updated' : 'Removed'; 169 | let displayPathname = `app${pathname.replace(appPath, '')}`; 170 | console.log(); 171 | console.log( 172 | `${displayAction} ${displayPathname} styles and purged relevant stylesheets in ${millisecondsFromTimestamp( 173 | t0 174 | )}ms` 175 | ); 176 | console.log(); 177 | } 178 | } 179 | 180 | // #endregion 181 | 182 | // #region Functions to generate ASTs of tailwindcss styles and and ultimately export stylesheets 183 | 184 | /** 185 | * Generates an AST of and creates a file for the root/global styles 186 | * @param {string} contentPathname 187 | * @returns {Promise<{ast: csstree.CssNode; classNames: Set;}>} AST and Set of classNames 188 | */ 189 | async function generateAndSaveRootTailwindStyles( 190 | contentPathname = `${root},${appPath}/components/**/*.{js,jsx,ts,tsx}` 191 | ) { 192 | let rootAst = await generateTailwindAst(baseTailwindCss, contentPathname); 193 | 194 | // Kick of the root stylesheet writing 195 | // This may be a bad idea to delay the return of classNames until this is 196 | // finished, however I believe this will pretty much always be done before 197 | // the rest of the ASTs are generated 198 | let rootStylesheet = csstree.generate(rootAst); 199 | await writeFile(rootStylesPath, rootStylesheet); 200 | 201 | return { 202 | ast: rootAst, 203 | classNames: getClassNames(rootAst), 204 | }; 205 | } 206 | 207 | /** 208 | * 209 | * @param {Map;}>} routeAstMap 210 | * @param {Set} rootClassNames 211 | * @param {null | Set} dirtyPathnames If null, all paths are dirty, otherwise only update anything relying on the dirty path 212 | * @returns 213 | */ 214 | async function generateAndSaveRouteTailwindStyles( 215 | routeAstMap, 216 | rootClassNames, 217 | dirtyPathnames = null 218 | ) { 219 | let fileWritingPromises = []; 220 | // Map over all of the route stylesheets, create a set of ancestor classNames, purge the stylesheet, and write it 221 | for (let pathname of routeAstMap.keys()) { 222 | let shouldUpdate = dirtyPathnames === null; // if null, all paths are dirty, so definitely update 223 | let ancestorPathnames = getAncestorPathnames(pathname); 224 | // Every route has root as the ancestor 225 | let ancestorClassNames = rootClassNames; 226 | 227 | shouldUpdate = shouldUpdate || dirtyPathnames.has(pathname); 228 | 229 | for (let ancestorPathname of ancestorPathnames) { 230 | // Skip ancestorPathnames that don't exist 231 | if (!routeAstMap.has(ancestorPathname)) continue; 232 | let { classNames } = routeAstMap.get(ancestorPathname); 233 | ancestorClassNames = new Set([...rootClassNames, ...classNames]); 234 | shouldUpdate = shouldUpdate || dirtyPathnames.has(ancestorPathname); 235 | } 236 | 237 | // Skip routes we're not updating 238 | if (!shouldUpdate) continue; 239 | 240 | let { ast } = routeAstMap.get(pathname); 241 | let stylesheetText = generatePurgedStylesheet(ast, ancestorClassNames); 242 | 243 | let promise = writeFile(getCssPathname(pathname), stylesheetText); 244 | fileWritingPromises.push(promise); 245 | } 246 | 247 | return Promise.all(fileWritingPromises); 248 | } 249 | 250 | /** 251 | * Generates and loops over a list of file paths and generates the tailwind styles for each file, 252 | * returning an AST of the styles in a Map keyed by the route path 253 | * Note: The pathname keys have their extension stripped 254 | * @returns {Promise}>>} Map of file path to AST and Set of classNames 255 | */ 256 | async function generateRouteTailwindAstMap() { 257 | let filePaths = await getAllFilePaths(); 258 | let entryPromises = filePaths.map((pathname) => 259 | generateRouteTailwindAstEntry(pathname) 260 | ); 261 | let entries = await Promise.all(entryPromises); 262 | return new Map(entries); 263 | } 264 | 265 | /** 266 | * Create a single entry for the map of route pathnames to AST/className 267 | * @param {string} pathname 268 | * @returns {Promise<[string, {ast: csstree.CssNode; classNames: Set;}]>} 269 | */ 270 | async function generateRouteTailwindAstEntry(pathname) { 271 | // drop the extension for the route path—this helps with matching parent directories later 272 | let extensionRegex = new RegExp(`${path.extname(pathname)}$`); 273 | let extensionlessPathname = pathname.replace(extensionRegex, ''); 274 | let ast = await generateTailwindAst(routeTailwindCss, pathname); 275 | let classNames = getClassNames(ast); 276 | let entry = [extensionlessPathname, { ast, classNames }]; 277 | return entry; 278 | } 279 | 280 | /** 281 | * Runs the tailwindcss CLI for a specific file then parses and returns an AST of the styles 282 | * @param {string} inputStylePathname 283 | * @param {string} contentPathname 284 | * @returns {Promise} AST of tailwindcss styles for contentPathname 285 | */ 286 | async function generateTailwindAst(inputStylePathname, contentPathname) { 287 | let twProcess = spawn( 288 | 'tailwindcss', 289 | ['-i', inputStylePathname, `--content=${contentPathname}`], 290 | { shell: true } 291 | ); 292 | let output = await promisifyTailwindProcess(twProcess); 293 | return csstree.parse(output); 294 | } 295 | 296 | /** 297 | * Walk the AST of a css file and remove classNames that appear in the ancestors 298 | * @param {csstree.CssNode} ast 299 | * @param {Set} ancestorClassNames 300 | * @returns {string} The purged css 301 | */ 302 | function generatePurgedStylesheet(ast, ancestorClassNames) { 303 | let cloneAst = csstree.clone(ast); 304 | // remove all classes that exist in the ancestor classNames 305 | csstree.walk(cloneAst, { 306 | visit: 'Rule', // this option is good for performance since reduces walking path length 307 | enter: function (node, item, list) { 308 | // since `visit` option is used, handler will be invoked for node.type === 'Rule' only 309 | if (selectorHasClassName(node.prelude, ancestorClassNames)) { 310 | list.remove(item); 311 | } 312 | }, 313 | }); 314 | 315 | return csstree.generate(cloneAst); 316 | } 317 | 318 | // #endregion 319 | 320 | // #region Various utilities used throughout 321 | 322 | /** 323 | * Recursively remove all the generated .css files to ensure we're starting fresh 324 | */ 325 | async function dumpCssFiles() { 326 | try { 327 | await Promise.all([ 328 | rm(rootStylesPath), 329 | rm(stylesRoutesPath, { recursive: true }), 330 | ]); 331 | } catch (error) { 332 | // if the directory doesn't exist just keep going 333 | if (error.code !== 'ENOENT') { 334 | throw error; 335 | } 336 | } 337 | } 338 | 339 | /** 340 | * Make all the styles directories we need 341 | * @param {string[] | IterableIterator} pathnames 342 | */ 343 | async function makeDirectories(pathnames) { 344 | // Create all the directories we might need 345 | let directoryPromise = []; 346 | let directories = new Set(); 347 | for (let pathname of pathnames) { 348 | let directory = path.dirname(getCssPathname(pathname)); 349 | // skip directories we're already working on 350 | if (directories.has(directory)) continue; 351 | 352 | // Make the directory and keep track of the promise/update the set 353 | directoryPromise.push(mkdir(directory, { recursive: true })); 354 | directories.add(directory); 355 | } 356 | 357 | // Group all directory creation promises into a single promise 358 | try { 359 | await Promise.all(directoryPromise); 360 | } catch (error) { 361 | // ignore if the directory already exists—just keep trucking 362 | if (error.code !== 'EEXIST') { 363 | throw error; 364 | } 365 | } 366 | } 367 | 368 | /** 369 | * Walk the AST of a css file and return a Set of the classNames 370 | * @param {csstree.CssNode} ast 371 | * @returns {Set} 372 | */ 373 | function getClassNames(ast) { 374 | let classNames = new Set(); 375 | 376 | csstree.walk(ast, { 377 | visit: 'ClassSelector', 378 | enter: function (node) { 379 | classNames.add(node.name); 380 | }, 381 | }); 382 | 383 | return classNames; 384 | } 385 | 386 | /** 387 | * Check if a selector has a className that exists in the ancestorClassNames 388 | * @param {csstree.Raw | csstree.SelectorList} selector 389 | * @param {Set} classNames Set of the classNames to check 390 | * @returns {boolean} 391 | */ 392 | function selectorHasClassName(selector, classNames) { 393 | return csstree.find( 394 | selector, 395 | (node) => node.type === 'ClassSelector' && classNames.has(node.name) 396 | ); 397 | } 398 | 399 | /** 400 | * Turn a child processes resulting from calling `spawn` into promises 401 | * that resolves once the process closes 402 | * @param {import('child_process').ChildProcessWithoutNullStreams} twProcess 403 | * @returns 404 | */ 405 | function promisifyTailwindProcess(twProcess) { 406 | return new Promise((resolve, reject) => { 407 | let output = ''; 408 | twProcess.stdout.on('data', (data) => { 409 | output += String(data); 410 | }); 411 | 412 | twProcess.on('close', (code) => { 413 | resolve(output); 414 | }); 415 | 416 | twProcess.on('error', (error) => { 417 | reject(error.message); 418 | }); 419 | }); 420 | } 421 | 422 | /** 423 | * Recursively walks a directory and returns a list of all the file pathnames 424 | * @param {string} directoryPath Path of the directory with files to generate ASTs and recursively walk 425 | * @returns {Promise} List of file pathnames 426 | */ 427 | async function getAllFilePaths(directoryPath = routesPath) { 428 | let filePaths = []; 429 | let childrenDirectoryPromises = []; 430 | 431 | let files = await readdir(directoryPath, { withFileTypes: true }); 432 | 433 | for (let file of files) { 434 | let pathname = `${directoryPath}/${file.name}`; 435 | // Add all files to the list of file names and recursively walk children directories 436 | if (!file.isDirectory()) { 437 | filePaths.push(pathname); 438 | } else { 439 | childrenDirectoryPromises.push(getAllFilePaths(pathname)); 440 | } 441 | } 442 | 443 | // Add the child directory file names to the list of file names 444 | let childDirectoryFilePaths = await Promise.all(childrenDirectoryPromises); 445 | filePaths.push(...childDirectoryFilePaths.flat()); 446 | 447 | return filePaths; 448 | } 449 | 450 | /** 451 | * Takes a pathname and returns the file pathname for each possible layout file. 452 | * Assumes the pathname does not have it's extension and returns parent pathnames without extensions 453 | * For more information on how Remix handles layout hierarchy, see https://remix.run/docs/en/v1/guides/routing#rendering-route-layout-hierarchies 454 | * @param {string} pathname 455 | * @returns {string[]} List of ancestor pathnames 456 | */ 457 | function getAncestorPathnames(pathname) { 458 | let ext = path.extname(pathname); 459 | if (ext !== '') { 460 | throw new Error(`Pathname should not have an extension: ${pathname}`); 461 | } 462 | 463 | // remove everything up to './routes/' to only capture the pathnames we care about 464 | let relativePath = pathname.replace(`${routesPath}/`, ''); 465 | let segments = relativePath.split('/'); 466 | segments.pop(); // remove the last segment since we only want ancestor pathnames 467 | 468 | return segments.map((s) => path.join(routesPath, s)); 469 | } 470 | 471 | /** 472 | * Takes a pathname and returns the appropriate stylesheet (.css) file pathname 473 | * @param {string} pathname 474 | */ 475 | function getCssPathname(pathname) { 476 | // Remove everything up to './routes/' to only capture the pathnames we care about 477 | let relativePath = pathname.replace(`${routesPath}/`, ''); 478 | // Ensure extension is removed—regex taken from https://stackoverflow.com/a/4250408 479 | let extensionlessPathname = relativePath.replace(/\.[^/.]+$/, ''); 480 | return path.join(stylesRoutesPath, `${extensionlessPathname}.css`); 481 | } 482 | 483 | /** 484 | * Compares 2 sets to see if they contain the same elements 485 | * @param {Set} set1 486 | * @param {Set} set2 487 | * @returns {boolean} True if the sets contain the same elements 488 | */ 489 | function areSetsEqual(set1, set2) { 490 | if (set1.size !== set2.size) { 491 | return false; 492 | } 493 | for (let item of set1) { 494 | if (!set2.has(item)) { 495 | return false; 496 | } 497 | } 498 | return true; 499 | } 500 | 501 | /** 502 | * Returns a rounded difference between a timestamp and the current time 503 | * @param {*} t0 number 504 | */ 505 | function millisecondsFromTimestamp(t0) { 506 | return Math.round(performance.now() - t0); 507 | } 508 | 509 | // #endregion 510 | --------------------------------------------------------------------------------