├── 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 | [](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 | 
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 |
26 |
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 |
47 | Dismiss
48 |
49 |
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 |
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 |
58 |
59 |
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 |
70 |
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 |
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 |
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 |
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 |
76 |
77 | resume_back_end_developer.pdf
78 |
79 |
80 |
88 |
89 |
90 |
91 |
95 |
96 | coverletter_back_end_developer.pdf
97 |
98 |
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 |
36 |
37 |
38 |
39 |
40 |
82 |
83 |
92 |
96 |
97 |
98 |
99 |
104 |
105 |
106 |
107 | Close main menu
108 |
109 |
110 |
111 |
112 |
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 |
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 |
29 |
30 |
31 |
32 |
41 |
42 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | Options
106 |
110 |
111 |
112 |
113 |
122 |
123 |
151 |
179 |
207 |
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 |
81 |
82 |
83 |
84 | {navigation.map((item) => (
85 |
96 | {item.name}
97 |
98 | ))}
99 |
100 |
101 |
102 |
103 |
104 |
108 | View notifications
109 |
110 |
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 |
163 | ) : (
164 |
168 | )}
169 |
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 |
214 | View notifications
215 |
216 |
217 |
218 |
219 | {userNavigation.map((item) => (
220 |
226 | {item.name}
227 |
228 | ))}
229 |
230 |
231 |
232 | >
233 | )}
234 |
235 |
236 |
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 |
78 |
79 |
80 |
89 |
90 |
91 |
setOpen(false)}
95 | >
96 | Close
97 |
98 |
99 |
100 |
101 |
102 |
107 |
108 |
109 |
110 | {product.name}
111 |
112 |
113 |
117 |
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 |
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 |
128 |
129 |
130 |
131 |
132 | {({ open }) => (
133 | <>
134 |
140 | Solutions
141 |
148 |
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 |
236 |
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 |
349 |
350 |
351 |
352 | Close menu
353 |
354 |
355 |
356 |
357 |
376 |
377 |
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 |
--------------------------------------------------------------------------------