├── .prettierrc ├── .prettierignore ├── website ├── src │ ├── app │ │ ├── favicon.png │ │ ├── stylesheet.ts │ │ ├── layout.tsx │ │ ├── styles.css │ │ └── page.tsx │ ├── examples │ │ ├── index.ts │ │ ├── MediaQueries.tsx │ │ ├── BasicUsage.tsx │ │ ├── PseudoSelectors.tsx │ │ ├── StylingVariantsAndScopes.tsx │ │ ├── CSSVariables.tsx │ │ ├── Keyframes.tsx │ │ └── ParentClassSupport.tsx │ └── components │ │ ├── Feature.tsx │ │ ├── Features.tsx │ │ ├── Section.tsx │ │ ├── Code.tsx │ │ ├── Example.tsx │ │ ├── CoreConcepts.tsx │ │ └── Introduction.tsx ├── public │ ├── vercel.svg │ ├── window.svg │ ├── file.svg │ ├── globe.svg │ └── next.svg ├── next.config.ts ├── eslint.config.mjs ├── .gitignore ├── tsconfig.json ├── package.json └── README.md ├── .vscode └── extensions.json ├── src ├── utils │ ├── asArray.ts │ ├── joinTruthy.ts │ ├── forIn.ts │ ├── stableHash.ts │ ├── stringManipulators.ts │ └── is.ts ├── cx.ts ├── __tests__ │ ├── __snapshots__ │ │ └── flairup.test.ts.snap │ ├── cx.test.ts │ └── flairup.test.ts ├── keyframes.ts ├── index.ts ├── Sheet.ts ├── types.ts ├── treeParseUtils.ts └── Rule.ts ├── tsconfig.spec.json ├── vite.config.ts ├── .gitignore ├── .github └── workflows │ └── deploy.yml ├── LICENSE ├── tsconfig.json ├── .eslintrc.js ├── package.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | /dist 3 | /coverage -------------------------------------------------------------------------------- /website/src/app/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ealush/flairup/HEAD/website/src/app/favicon.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | 4 | "nrwl.angular-console" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/asArray.ts: -------------------------------------------------------------------------------- 1 | export function asArray(v: T | T[]): T[] { 2 | return [].concat(v as unknown as []); 3 | } 4 | -------------------------------------------------------------------------------- /website/src/app/stylesheet.ts: -------------------------------------------------------------------------------- 1 | import { createSheet } from "flairup"; 2 | 3 | export const stylesheet = createSheet("flairup", null); -------------------------------------------------------------------------------- /website/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/joinTruthy.ts: -------------------------------------------------------------------------------- 1 | export function joinTruthy(arr: unknown[], delimiter: string = ''): string { 2 | return arr.filter(Boolean).join(delimiter); 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/forIn.ts: -------------------------------------------------------------------------------- 1 | export function forIn>( 2 | obj: O, 3 | fn: (key: string, value: O[string]) => void, 4 | ): void { 5 | for (const key in obj) { 6 | fn(key.trim(), obj[key]); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /website/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next'; 2 | 3 | const nextConfig: NextConfig = { 4 | output: 'export', 5 | images: { 6 | unoptimized: true, 7 | }, 8 | basePath: process.env.NODE_ENV === 'production' ? '/flairup' : '', 9 | trailingSlash: true, 10 | }; 11 | 12 | export default nextConfig; 13 | -------------------------------------------------------------------------------- /website/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/src/examples/index.ts: -------------------------------------------------------------------------------- 1 | export { BasicUsage } from './BasicUsage'; 2 | export { StylingVariantsAndScopes } from './StylingVariantsAndScopes'; 3 | export { CSSVariables } from './CSSVariables'; 4 | export { MediaQueries } from './MediaQueries'; 5 | export { PseudoSelectors } from './PseudoSelectors'; 6 | export { ParentClassSupport } from './ParentClassSupport'; 7 | export { Keyframes } from './Keyframes'; -------------------------------------------------------------------------------- /src/utils/stableHash.ts: -------------------------------------------------------------------------------- 1 | // Stable hash function. 2 | export function stableHash(prefix: string, seed: string): string { 3 | let hash = 0; 4 | if (seed.length === 0) return hash.toString(); 5 | for (let i = 0; i < seed.length; i++) { 6 | const char = seed.charCodeAt(i); 7 | hash = (hash << 5) - hash + char; 8 | hash = hash & hash; // Convert to 32bit integer 9 | } 10 | return `${prefix ?? 'cl'}_${hash.toString(36)}`; 11 | } 12 | -------------------------------------------------------------------------------- /website/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /website/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "./styles.css"; 3 | import { stylesheet } from "./stylesheet"; 4 | 5 | 6 | export const metadata: Metadata = { 7 | title: "FlairUp 🎩", 8 | description: "CSS in JS Library for Shareable Components", 9 | }; 10 | 11 | export default function RootLayout({ 12 | children, 13 | }: Readonly<{ 14 | children: React.ReactNode; 15 | }>) { 16 | return ( 17 | 18 | 19 | 20 | {children} 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [ 6 | "vitest/globals", 7 | "vitest/importMeta", 8 | "vite/client", 9 | "node", 10 | "vitest" 11 | ] 12 | }, 13 | "include": [ 14 | "vite.config.ts", 15 | "vitest.config.ts", 16 | "src/**/*.test.ts", 17 | "src/**/*.spec.ts", 18 | "src/**/*.test.tsx", 19 | "src/**/*.spec.tsx", 20 | "src/**/*.test.js", 21 | "src/**/*.spec.js", 22 | "src/**/*.test.jsx", 23 | "src/**/*.spec.jsx", 24 | "src/**/*.d.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | root: __dirname, 6 | cacheDir: '../../node_modules/.vite/packages/flairup', 7 | 8 | plugins: [], 9 | 10 | test: { 11 | globals: true, 12 | cache: { 13 | dir: './node_modules/.vitest', 14 | }, 15 | environment: 'jsdom', 16 | include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 17 | 18 | reporters: ['default'], 19 | coverage: { 20 | reportsDirectory: './coverage/packages/flairup', 21 | provider: 'v8', 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | 41 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/cx.ts: -------------------------------------------------------------------------------- 1 | import { joinTruthy } from './utils/joinTruthy'; 2 | 3 | export function cx(...args: unknown[]): string { 4 | const classes = args.reduce((classes: string[], arg) => { 5 | if (arg instanceof Set) { 6 | classes.push(...arg); 7 | } else if (typeof arg === 'string') { 8 | classes.push(arg); 9 | } else if (Array.isArray(arg)) { 10 | classes.push(cx(...arg)); 11 | } else if (typeof arg === 'object') { 12 | // @ts-expect-error - it is a string 13 | Object.entries(arg).forEach(([key, value]) => { 14 | if (value) { 15 | classes.push(key); 16 | } 17 | }); 18 | } 19 | 20 | return classes; 21 | }, [] as string[]); 22 | 23 | return joinTruthy(classes, ' ').trim(); 24 | } 25 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/flairup.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`createSheet > Keyframes > Should create keyframes > 4 | "@keyframes test { 5 | 0% {color:red;} 6 | 100% {color:blue;} 7 | }" 8 | 1`] = ` 9 | "@keyframes test { 10 | 0% { 11 | color:red; 12 | } 13 | 100% { 14 | color:blue; 15 | } 16 | }" 17 | `; 18 | 19 | exports[`createSheet > Keyframes > Should create keyframes > 20 | 1`] = ` 21 | "@keyframes test { 22 | 0% { 23 | color:red; 24 | } 25 | 100% { 26 | color:blue; 27 | } 28 | }" 29 | `; 30 | 31 | exports[`createSheet > Keyframes > Should create keyframes 1`] = ` 32 | "@keyframes test { 33 | 0% { 34 | color:red; 35 | } 36 | 100% { 37 | color:blue; 38 | } 39 | }" 40 | `; 41 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "predeploy": "npm run build", 11 | "deploy": "gh-pages -d out" 12 | }, 13 | "dependencies": { 14 | "flairup": "^1.1.0", 15 | "next": "15.3.0", 16 | "prism-react-renderer": "^2.4.1", 17 | "react": "^19.0.0", 18 | "react-dom": "^19.0.0" 19 | }, 20 | "homepage": "https://ealush.github.io/flairup", 21 | "devDependencies": { 22 | "@eslint/eslintrc": "^3", 23 | "@types/node": "^20", 24 | "@types/react": "^19", 25 | "@types/react-dom": "^19", 26 | "eslint": "^9", 27 | "eslint-config-next": "15.3.0", 28 | "gh-pages": "^6.1.1", 29 | "prettier": "3.5.3", 30 | "typescript": "^5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/stringManipulators.ts: -------------------------------------------------------------------------------- 1 | // Some properties need special handling 2 | export function handlePropertyValue(property: string, value: string): string { 3 | if (property === 'content') { 4 | return `"${value}"`; 5 | } 6 | 7 | return value; 8 | } 9 | 10 | export function camelCaseToDash(str: string): string { 11 | return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); 12 | } 13 | 14 | export function joinedProperty(property: string, value: string): string { 15 | return `${property}:${value}`; 16 | } 17 | 18 | export function toClass(str: string): string { 19 | return str ? `.${str}` : ''; 20 | } 21 | 22 | export function appendString(base: string, line: string): string { 23 | return appendStringInline(base, line, '\n'); 24 | } 25 | 26 | export function appendStringInline( 27 | base: string, 28 | line: string, 29 | separator: string = ' ', 30 | ): string { 31 | return base ? `${base}${separator}${line}` : line; 32 | } 33 | -------------------------------------------------------------------------------- /website/src/components/Feature.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from 'flairup'; 3 | import { stylesheet } from '../app/stylesheet'; 4 | 5 | const styles = stylesheet.create({ 6 | featureItem: { 7 | backgroundColor: 'var(--card-background)', 8 | padding: '15px', 9 | borderRadius: '5px', 10 | boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', 11 | }, 12 | featureTitle: { 13 | fontSize: '1.3em', 14 | fontWeight: 'bold', 15 | marginBottom: '0.5em', 16 | color: '#3498db', 17 | }, 18 | featureDescription: { 19 | color: 'var(--card-text)', 20 | }, 21 | }); 22 | 23 | interface FeatureProps { 24 | title: string; 25 | children: React.ReactNode; 26 | } 27 | 28 | export function Feature({ title, children }: FeatureProps) { 29 | return ( 30 |
31 |

{title}

32 |

{children}

33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | build-and-deploy: 9 | runs-on: ubuntu-latest 10 | defaults: 11 | run: 12 | working-directory: website 13 | permissions: 14 | contents: write 15 | pages: write 16 | id-token: write 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: '18' 25 | cache: 'npm' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Build 31 | run: npm run build 32 | working-directory: website 33 | 34 | - name: Deploy to GitHub Pages 35 | uses: peaceiris/actions-gh-pages@v3 36 | with: 37 | github_token: ${{ secrets.GITHUB_TOKEN }} 38 | publish_dir: ./website/out 39 | publish_branch: gh-pages 40 | -------------------------------------------------------------------------------- /website/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ealush 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to **deal** 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /website/src/components/Features.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from 'flairup'; 3 | import { stylesheet } from '../app/stylesheet'; 4 | import { Section } from './Section'; 5 | import { Feature } from './Feature'; 6 | 7 | const styles = stylesheet.create({ 8 | features: { 9 | display: 'grid', 10 | gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', 11 | gap: '20px', 12 | marginBottom: '40px', 13 | }, 14 | }); 15 | 16 | export function Features() { 17 | return ( 18 |
19 |
20 | 21 | Easy to use with a familiar CSS-like syntax 22 | 23 | 24 | Full TypeScript support for better development experience 25 | 26 | 27 | Automatic style scoping to prevent conflicts 28 | 29 | 30 | Built-in support for server-side rendering 31 | 32 |
33 |
34 | ); 35 | } -------------------------------------------------------------------------------- /src/keyframes.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from './Rule'; 2 | import { Sheet } from './Sheet'; 3 | import { 4 | keyframesInput, 5 | KeyframesOutput, 6 | KeyframeStages, 7 | StyleObject, 8 | } from './types'; 9 | import { forIn } from './utils/forIn'; 10 | import { isValidProperty } from './utils/is'; 11 | 12 | export function addKeyframes( 13 | sheet: Sheet, 14 | input: keyframesInput, 15 | ): KeyframesOutput { 16 | const keyframes: Record = {} as KeyframesOutput; 17 | 18 | forIn(input, (name: string, stages: KeyframeStages) => { 19 | const hashed = [sheet.name, sheet.seq(), name].join('_'); 20 | keyframes[name as KF] = hashed; 21 | sheet.append(`@keyframes ${hashed} {`); 22 | 23 | forIn(stages, (stage: string, stylesObject: StyleObject) => { 24 | sheet.append(`${stage} {`); 25 | forIn(stylesObject, (key: string, value) => { 26 | if (isValidProperty(key, value)) { 27 | sheet.appendInline(Rule.genRule(key, value)); 28 | } 29 | }); 30 | sheet.appendInline('}'); 31 | }); 32 | sheet.append('}'); 33 | }); 34 | 35 | return keyframes; 36 | } 37 | -------------------------------------------------------------------------------- /website/src/components/Section.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from 'flairup'; 3 | import { stylesheet } from '../app/stylesheet'; 4 | 5 | const styles = stylesheet.create({ 6 | section: { 7 | marginBottom: '60px', 8 | paddingBottom: '40px', 9 | borderBottom: '1px solid #eee', 10 | '&:last-child': { 11 | borderBottom: 'none', 12 | marginBottom: '0', 13 | }, 14 | }, 15 | usageTitle: { 16 | fontSize: '1.8em', 17 | fontWeight: 'bold', 18 | marginBottom: '1.2em', 19 | color: 'var(--title-color)', 20 | position: 'relative', 21 | '&::after': { 22 | content: '', 23 | position: 'absolute', 24 | bottom: '-10px', 25 | left: '0', 26 | width: '50px', 27 | height: '3px', 28 | backgroundColor: '#3498db', 29 | borderRadius: '2px', 30 | }, 31 | }, 32 | }); 33 | 34 | interface SectionProps { 35 | title: string; 36 | children: React.ReactNode; 37 | } 38 | 39 | export function Section({ title, children }: SectionProps) { 40 | return ( 41 |
42 |

{title}

43 | {children} 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /website/src/examples/MediaQueries.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from 'flairup'; 3 | import { stylesheet } from '../app/stylesheet'; 4 | import { Example } from '../components/Example'; 5 | 6 | const exampleStyle = { 7 | box: { 8 | backgroundColor: 'lightblue', 9 | padding: '20px', 10 | color: '#000', 11 | '@media (max-width: 600px)': { 12 | backgroundColor: 'lightcoral', 13 | padding: '10px', 14 | }, 15 | }, 16 | }; 17 | 18 | const styles = stylesheet.create({ 19 | ...exampleStyle, 20 | }); 21 | 22 | export function MediaQueries() { 23 | return ( 24 | 31 | Resize the window to see the effect 32 | 33 | ); 34 | }`} 35 | > 36 |
Resize the window to see the effect
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /website/src/app/styles.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap'); 2 | 3 | :root { 4 | --background: #ffffff; 5 | --foreground: #171717; 6 | --card-background: #f5f5f5; 7 | --card-text: #171717; 8 | --title-color: #3498db; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --background: #222432; 14 | --foreground: #ededed; 15 | --card-background: rgba(23, 23, 23, 0.7); 16 | --card-text: #ededed; 17 | } 18 | } 19 | 20 | html, 21 | body { 22 | max-width: 100vw; 23 | overflow-x: hidden; 24 | } 25 | 26 | body { 27 | color: var(--foreground); 28 | background: var(--background); 29 | font-family: 'Open Sans', Arial, Helvetica, sans-serif; 30 | -webkit-font-smoothing: antialiased; 31 | -moz-osx-font-smoothing: grayscale; 32 | font-optical-sizing: auto; 33 | font-weight: 400; 34 | font-style: normal; 35 | font-variation-settings: 'wdth' 100; 36 | } 37 | 38 | * { 39 | box-sizing: border-box; 40 | padding: 0; 41 | margin: 0; 42 | } 43 | 44 | a { 45 | color: inherit; 46 | text-decoration: none; 47 | } 48 | 49 | @media (prefers-color-scheme: dark) { 50 | html { 51 | color-scheme: dark; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /website/src/examples/BasicUsage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from 'flairup'; 3 | import { stylesheet } from '../app/stylesheet'; 4 | import { Example } from '../components/Example'; 5 | 6 | const exampleStyle = { 7 | button: { 8 | color: 'blue', 9 | backgroundColor: 'white', 10 | padding: '10px 20px', 11 | border: '1px solid blue', 12 | borderRadius: '5px', 13 | cursor: 'pointer', 14 | ':hover': { 15 | backgroundColor: 'lightblue', 16 | borderColor: 'darkblue', 17 | }, 18 | }, 19 | }; 20 | 21 | const styles = stylesheet.create({ 22 | ...exampleStyle, 23 | }); 24 | 25 | export function BasicUsage() { 26 | return ( 27 | 34 | Hover me! 35 | 36 | ); 37 | }`} 38 | > 39 | 40 | 41 | ); 42 | } -------------------------------------------------------------------------------- /website/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/__tests__/cx.test.ts: -------------------------------------------------------------------------------- 1 | import { cx } from '../index.js'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | describe('cx', () => { 5 | it('should return a string', () => { 6 | expect(typeof cx()).toBe('string'); 7 | }); 8 | 9 | it('Should concatenate array arguments', () => { 10 | expect(cx(['a', 'b'])).toBe('a b'); 11 | }); 12 | 13 | it('When taken it object, it should only add truthy keys', () => { 14 | expect(cx({ a: true, b: false })).toBe('a'); 15 | expect(cx({ yes: 1, no: 0 })).toBe('yes'); 16 | expect(cx({ yes: 1, no: 0, maybe: 1 })).toBe('yes maybe'); 17 | }); 18 | 19 | it("Should concatenate sets' values", () => { 20 | expect(cx(new Set(['a', 'b']))).toBe('a b'); 21 | expect(cx(new Set(['a', 'b']), new Set(['c', 'd']))).toBe('a b c d'); 22 | }); 23 | 24 | it('Should concatenate nested arrays', () => { 25 | expect(cx(['a', ['b', 'c']])).toBe('a b c'); 26 | }); 27 | 28 | it("Should concatenate object in nested arrays' values", () => { 29 | expect(cx(['a', ['b', { c: true }]])).toBe('a b c'); 30 | expect(cx(['a', ['b', { c: false }]])).toBe('a b'); 31 | }); 32 | 33 | it('Should allow mixing of types when passing multiple arguments', () => { 34 | expect(cx('a', ['b', 'c'], { d: true })).toBe('a b c d'); 35 | expect(cx('a', ['b', 'c'], { d: false })).toBe('a b c'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationMap": true, 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "inlineSources": true, 8 | "jsx": "react", 9 | "module": "nodenext", 10 | "moduleResolution": "nodenext", 11 | "noUncheckedIndexedAccess": true, 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "target": "es5", 17 | "noFallthroughCasesInSwitch": true, 18 | "noUnusedParameters": true, 19 | "allowUnreachableCode": false, 20 | "allowUnusedLabels": false, 21 | "noImplicitUseStrict": false, 22 | "suppressExcessPropertyErrors": false, 23 | "suppressImplicitAnyIndexErrors": false, 24 | "noStrictGenericChecks": false, 25 | "experimentalDecorators": true, 26 | "importHelpers": true, 27 | "typeRoots": ["node_modules/@types"], 28 | "lib": ["es2017", "dom"], 29 | "skipDefaultLibCheck": true, 30 | "baseUrl": ".", 31 | "alwaysStrict": true, 32 | "noImplicitAny": true, 33 | "strictNullChecks": true, 34 | "useUnknownInCatchVariables": true, 35 | "strictPropertyInitialization": true, 36 | "strictFunctionTypes": true, 37 | "noImplicitThis": true, 38 | "strictBindCallApply": true, 39 | "noPropertyAccessFromIndexSignature": true, 40 | "exactOptionalPropertyTypes": true, 41 | "noImplicitReturns": true, 42 | "noImplicitOverride": true, 43 | "rootDir": ".", 44 | "noEmit": false, 45 | "noUnusedLocals": true 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /src/utils/is.ts: -------------------------------------------------------------------------------- 1 | import { ClassName } from '../types.js'; 2 | 3 | export function isPsuedoSelector(selector: string): boolean { 4 | return selector.startsWith(':'); 5 | } 6 | 7 | export function isStyleCondition(selector: string): boolean { 8 | return ( 9 | isString(selector) && 10 | (selector === '*' || 11 | (selector.length > 1 && ':>~.+*'.includes(selector.slice(0, 1))) || 12 | isImmediatePostcondition(selector)) 13 | ); 14 | } 15 | 16 | export function isValidProperty( 17 | property: string, 18 | value: unknown, 19 | ): value is string { 20 | return ( 21 | (isString(value) || typeof value === 'number') && 22 | !isCssVariables(property) && 23 | !isPsuedoSelector(property) && 24 | !isMediaQuery(property) 25 | ); 26 | } 27 | 28 | export function isMediaQuery(selector: string): boolean { 29 | return selector.startsWith('@media'); 30 | } 31 | 32 | export function isKeyframes(selector: string): boolean { 33 | return selector.startsWith('@keyframes'); 34 | } 35 | 36 | export function isDirectClass(selector: string): boolean { 37 | return selector === '.'; 38 | } 39 | 40 | export function isCssVariables(selector: string): boolean { 41 | return selector === '--'; 42 | } 43 | 44 | export function isString(value: unknown): value is string { 45 | return value + '' === value; 46 | } 47 | 48 | export function isClassName(value: unknown): value is ClassName { 49 | return isString(value) && value.length > 1 && value.startsWith('.'); 50 | } 51 | 52 | export function isImmediatePostcondition( 53 | value: unknown, 54 | ): value is `&${string}` { 55 | return isString(value) && (value.startsWith('&') || isPsuedoSelector(value)); 56 | } 57 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | ignorePatterns: ['!src/**/*'], 7 | parser: '@typescript-eslint/parser', 8 | plugins: ['@typescript-eslint'], 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | 'prettier', 13 | ], 14 | // override max lines rule for test files 15 | overrides: [ 16 | { 17 | files: ['**/*.test.ts'], 18 | rules: { 19 | 'max-lines-per-function': 'off', 20 | 'max-nested-callbacks': 'off', 21 | 'max-statements': 'off', 22 | }, 23 | }, 24 | ], 25 | rules: { 26 | '@typescript-eslint/no-unused-vars': 2, 27 | '@typescript-eslint/no-explicit-any': 2, 28 | '@typescript-eslint/explicit-module-boundary-types': 2, 29 | '@typescript-eslint/semi': 2, 30 | semi: 2, 31 | 'no-unused-vars': 2, 32 | 'no-console': 2, 33 | 'max-depth': [2, { max: 3 }], 34 | 'max-lines-per-function': [ 35 | 2, 36 | { max: 40, skipComments: true, skipBlankLines: true }, 37 | ], 38 | 'max-nested-callbacks': [2, { max: 3 }], 39 | 'max-statements': [2, { max: 9 }], 40 | 'max-params': [2, { max: 4 }], 41 | 'no-else-return': 2, 42 | 'no-implicit-globals': 2, 43 | 'no-lonely-if': 2, 44 | 'no-multi-spaces': 2, 45 | 'no-prototype-builtins': 2, 46 | 'no-trailing-spaces': [2, { ignoreComments: false }], 47 | 'no-undef': 2, 48 | 'no-unneeded-ternary': 2, 49 | 'no-unused-expressions': 2, 50 | 'no-useless-catch': 2, 51 | 'no-useless-computed-key': 2, 52 | 'no-useless-return': 2, 53 | 'no-var': 2, 54 | 'no-warning-comments': 1, 55 | 'object-shorthand': [2, 'always', { avoidQuotes: true }], 56 | 'prefer-const': 2, 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /website/src/components/Code.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from 'flairup'; 3 | import { stylesheet } from '../app/stylesheet'; 4 | import { Highlight, themes } from 'prism-react-renderer'; 5 | 6 | const styles = stylesheet.create({ 7 | codeExample: { 8 | backgroundColor: '#f5f5f5', 9 | padding: '15px', 10 | borderRadius: '5px', 11 | overflowX: 'auto', 12 | margin: '1em 0', 13 | fontFamily: 'monospace', 14 | }, 15 | pre: { 16 | margin: '0', 17 | padding: '0', 18 | }, 19 | line: { 20 | display: 'table-row', 21 | }, 22 | lineNumber: { 23 | display: 'table-cell', 24 | textAlign: 'right', 25 | paddingRight: '1em', 26 | userSelect: 'none', 27 | opacity: '0.5', 28 | }, 29 | lineContent: { 30 | display: 'table-cell', 31 | }, 32 | }); 33 | 34 | interface CodeProps { 35 | children: string; 36 | language?: string; 37 | } 38 | 39 | export function Code({ children, language = 'typescript' }: CodeProps) { 40 | return ( 41 | 46 | {({ className, style, tokens, getLineProps, getTokenProps }) => ( 47 |
48 |           {tokens.map((line, i) => (
49 |             
50 | {i + 1} 51 | 52 | {line.map((token, key) => ( 53 | 54 | ))} 55 | 56 |
57 | ))} 58 |
59 | )} 60 |
61 | ); 62 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flairup", 3 | "description": "🎩 Lightweight CSS-in-JS solution for npm packages", 4 | "version": "1.1.1", 5 | "homepage": "https://ealush.com/flairup", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/ealush/flairup.git" 10 | }, 11 | "keywords": [ 12 | "CSS-in-JS", 13 | "Component-styling", 14 | "Third-party-styling", 15 | "One-time-runtime", 16 | "style", 17 | "StyleSheet", 18 | "CSS", 19 | "CSS-variables", 20 | "Scoping-styles", 21 | "Custom-class names", 22 | "zero-config", 23 | "reusable", 24 | "scoped-styls" 25 | ], 26 | "main": "./dist/index.js", 27 | "devDependencies": { 28 | "@swc/core": "^1.3.102", 29 | "@typescript-eslint/eslint-plugin": "^6.16.0", 30 | "@typescript-eslint/parser": "^6.16.0", 31 | "eslint": "^8.56.0", 32 | "eslint-config-prettier": "^9.1.0", 33 | "jsdom": "^23.0.1", 34 | "prettier": "^3.1.1", 35 | "tsup": "^8.0.1", 36 | "typescript": "^5.3.3", 37 | "vite": "^5.0.10", 38 | "vitest": "^1.1.1" 39 | }, 40 | "files": [ 41 | "dist" 42 | ], 43 | "scripts": { 44 | "build": "tsup ./src/index.ts --target es5", 45 | "lint": "eslint --ext .ts ./src", 46 | "test": "vitest --watch=false", 47 | "prepublish": "npm run test && npm run build" 48 | }, 49 | "tsup": { 50 | "sourcemap": true, 51 | "dts": true, 52 | "clean": true, 53 | "legacyOutput": true, 54 | "format": [ 55 | "cjs", 56 | "esm" 57 | ] 58 | }, 59 | "exports": { 60 | "./package.json": "./package.json", 61 | ".": { 62 | "import": { 63 | "types": "./dist/index.d.ts", 64 | "default": "./dist/esm/index.js" 65 | }, 66 | "require": { 67 | "types": "./dist/index.d.ts", 68 | "default": "./dist/index.js" 69 | } 70 | } 71 | }, 72 | "types": "./dist/index.d.ts" 73 | } 74 | -------------------------------------------------------------------------------- /website/src/examples/PseudoSelectors.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from 'flairup'; 3 | import { stylesheet } from '../app/stylesheet'; 4 | import { Example } from '../components/Example'; 5 | 6 | const exampleStyle = { 7 | button: { 8 | backgroundColor: '#f1c40f', 9 | color: 'white', 10 | padding: '10px 20px', 11 | borderRadius: '5px', 12 | border: 'none', 13 | cursor: 'pointer', 14 | position: 'relative', 15 | transition: 'all 0.3s ease', 16 | ':hover': { 17 | backgroundColor: '#f39c12', 18 | transform: 'translateY(-2px)', 19 | }, 20 | ':active': { 21 | transform: 'translateY(0)', 22 | }, 23 | ':focus': { 24 | outline: 'none', 25 | boxShadow: '0 0 0 3px rgba(241, 196, 15, 0.4)', 26 | }, 27 | '::before': { 28 | content: '🎩', 29 | position: 'absolute', 30 | left: '10px', 31 | top: '50%', 32 | transform: 'translateY(-50%)', 33 | }, 34 | '::after': { 35 | content: '→', 36 | position: 'absolute', 37 | right: '10px', 38 | top: '50%', 39 | transform: 'translateY(-50%)', 40 | }, 41 | }, 42 | }; 43 | 44 | const styles = stylesheet.create({ 45 | ...exampleStyle, 46 | }); 47 | 48 | export function PseudoSelectors() { 49 | return ( 50 | 57 | Hover me! 58 | 59 | ); 60 | }`} 61 | > 62 | 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { addKeyframes } from './keyframes.js'; 2 | import { Selector } from './Rule.js'; 3 | import { Sheet } from './Sheet.js'; 4 | import { iterateTopLevel, iterateStyles } from './treeParseUtils.js'; 5 | import { 6 | CreateSheetInput, 7 | KeyframesOutput, 8 | ScopedStyles, 9 | Styles, 10 | createSheetReturn, 11 | keyframesInput, 12 | } from './types.js'; 13 | 14 | export { cx } from './cx.js'; 15 | 16 | export type { CreateSheetInput, Styles }; 17 | 18 | export function createSheet( 19 | name: string, 20 | rootNode?: HTMLElement | null, 21 | ): createSheetReturn { 22 | const sheet = new Sheet(name, rootNode); 23 | 24 | return { 25 | create: genCreate(sheet), 26 | keyframes: genKeyframes(sheet), 27 | getStyle: sheet.getStyle.bind(sheet), 28 | isApplied: sheet.isApplied.bind(sheet), 29 | }; 30 | } 31 | 32 | function genCreate( 33 | sheet: Sheet, 34 | ): (styles: CreateSheetInput) => ScopedStyles & ScopedStyles { 35 | return function create(styles: CreateSheetInput) { 36 | const scopedStyles: ScopedStyles = {} as ScopedStyles; 37 | 38 | const topLevel = iterateTopLevel(sheet, styles, new Selector(sheet)); 39 | 40 | topLevel.preconditions.forEach(([scopeName, styles, selector]) => { 41 | iterateStyles(sheet, styles as Styles, selector).forEach((className) => { 42 | addScopedStyle(scopeName as K, className); 43 | }); 44 | }); 45 | 46 | // Commit the styles to the sheet. 47 | // Done only once per create call. 48 | // This way we do not update the DOM on every style. 49 | sheet.apply(); 50 | 51 | return scopedStyles; 52 | 53 | function addScopedStyle(name: K, className: string) { 54 | scopedStyles[name as keyof ScopedStyles] = 55 | scopedStyles[name as keyof ScopedStyles] ?? new Set(); 56 | scopedStyles[name as keyof ScopedStyles].add(className); 57 | } 58 | }; 59 | } 60 | 61 | function genKeyframes(sheet: Sheet) { 62 | return function keyframes( 63 | keyframesInput: keyframesInput, 64 | ): KeyframesOutput { 65 | return addKeyframes(sheet, keyframesInput); 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /website/src/examples/StylingVariantsAndScopes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from 'flairup'; 3 | import { stylesheet } from '../app/stylesheet'; 4 | import { Example } from '../components/Example'; 5 | 6 | const exampleStyle = { 7 | button: { 8 | backgroundColor: '#3498db', 9 | color: 'white', 10 | padding: '10px 20px', 11 | borderRadius: '5px', 12 | border: 'none', 13 | cursor: 'pointer', 14 | '&:hover': { 15 | backgroundColor: '#2980b9', 16 | }, 17 | }, 18 | primary: { 19 | backgroundColor: '#2ecc71', 20 | '&:hover': { 21 | backgroundColor: '#27ae60', 22 | }, 23 | }, 24 | danger: { 25 | backgroundColor: '#e74c3c', 26 | '&:hover': { 27 | backgroundColor: '#c0392b', 28 | }, 29 | }, 30 | }; 31 | 32 | const styles = stylesheet.create({ 33 | buttonGroup: { 34 | display: 'flex', 35 | gap: '1em', 36 | }, 37 | ...exampleStyle, 38 | }); 39 | 40 | export function StylingVariantsAndScopes() { 41 | return ( 42 | 49 | 50 | 51 | 52 | 53 | ); 54 | }`} 55 | > 56 |
57 | 58 | 59 | 60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /website/src/components/Example.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from 'flairup'; 3 | import { stylesheet } from '../app/stylesheet'; 4 | import { Code } from './Code'; 5 | 6 | const styles = stylesheet.create({ 7 | container: { 8 | marginBottom: '2em', 9 | }, 10 | title: { 11 | fontSize: '1.2em', 12 | fontWeight: '600', 13 | color: 'var(--title-color)', 14 | marginBottom: '0.5em', 15 | }, 16 | description: { 17 | color: 'var(--card-text)', 18 | marginBottom: '1em', 19 | lineHeight: '1.5', 20 | }, 21 | example: { 22 | backgroundColor: 'var(--card-background)', 23 | padding: '1.5em', 24 | borderRadius: '8px', 25 | boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', 26 | border: '1px solid #eee', 27 | marginBottom: '1em', 28 | }, 29 | codeSection: { 30 | marginTop: '1em', 31 | }, 32 | codeTitle: { 33 | fontSize: '1em', 34 | fontWeight: '600', 35 | color: 'var(--title-color)', 36 | marginBottom: '0.5em', 37 | }, 38 | }); 39 | 40 | interface ExampleProps { 41 | title: string; 42 | description: string | React.ReactNode; 43 | exampleStyle: Record | string; 44 | children: React.ReactNode; 45 | usage?: string; 46 | } 47 | 48 | export function Example({ 49 | title, 50 | description, 51 | exampleStyle, 52 | children, 53 | usage, 54 | }: ExampleProps) { 55 | const getCode = () => { 56 | if (typeof exampleStyle === 'string') { 57 | return exampleStyle; 58 | } 59 | 60 | return `const styles = sheet.create(${JSON.stringify(exampleStyle, null, 2)});`; 61 | }; 62 | 63 | return ( 64 |
65 |

{title}

66 |
{description}
67 |
{children}
68 |
69 |

Styles

70 | {getCode()} 71 |
72 | {usage && ( 73 |
74 |

Usage

75 | {usage} 76 |
77 | )} 78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/Sheet.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from './Rule.js'; 2 | import { StoredStyles } from './types.js'; 3 | import { isString } from './utils/is.js'; 4 | import { 5 | appendString, 6 | appendStringInline, 7 | } from './utils/stringManipulators.js'; 8 | 9 | export class Sheet { 10 | private styleTag: HTMLStyleElement | undefined; 11 | 12 | // Hash->css 13 | private storedStyles: StoredStyles = {}; 14 | 15 | // styles->hash 16 | private storedClasses: Record = {}; 17 | private style: string = ''; 18 | public count = 0; 19 | public id: string; 20 | 21 | constructor( 22 | public name: string, 23 | private rootNode?: HTMLElement | null, 24 | ) { 25 | this.id = `flairup-${name}`; 26 | 27 | this.styleTag = this.createStyleTag(); 28 | } 29 | 30 | getStyle(): string { 31 | return this.style; 32 | } 33 | 34 | append(css: string): void { 35 | this.style = appendString(this.style, css); 36 | } 37 | 38 | appendInline(css: string): void { 39 | this.style = appendStringInline(this.style, css); 40 | } 41 | 42 | seq(): number { 43 | return this.count++; 44 | } 45 | 46 | apply(): void { 47 | this.seq(); 48 | 49 | if (!this.styleTag) { 50 | return; 51 | } 52 | 53 | this.styleTag.innerHTML = this.style; 54 | } 55 | 56 | isApplied(): boolean { 57 | return !!this.styleTag; 58 | } 59 | 60 | createStyleTag(): HTMLStyleElement | undefined { 61 | // check that we're in the browser and have access to the DOM 62 | if ( 63 | typeof document === 'undefined' || 64 | this.isApplied() || 65 | // Explicitly disallow mounting to the DOM 66 | this.rootNode === null 67 | ) { 68 | return this.styleTag; 69 | } 70 | 71 | const styleTag = document.createElement('style'); 72 | styleTag.type = 'text/css'; 73 | styleTag.id = this.id; 74 | (this.rootNode ?? document.head).appendChild(styleTag); 75 | return styleTag; 76 | } 77 | 78 | addRule(rule: Rule): string { 79 | const storedClass = this.storedClasses[rule.key]; 80 | 81 | if (isString(storedClass)) { 82 | return storedClass; 83 | } 84 | 85 | this.storedClasses[rule.key] = rule.hash; 86 | this.storedStyles[rule.hash] = [rule.property, rule.value]; 87 | 88 | this.append(rule.toString()); 89 | return rule.hash; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type StyleObject = Partial; 2 | type Pseudo = `:${string}`; 3 | type MediaQuery = `@media ${string}`; 4 | export type ClassName = `.${string}`; 5 | export type CSSVariablesObject = Record<`--${string}`, string>; 6 | 7 | type ConditionPrefix = '.' | ':' | '~' | '+' | '*' | '>' | '&.' | '&:'; 8 | type ConditionKey = `${ConditionPrefix}${string}`; 9 | 10 | export type ClassSet = Set; 11 | 12 | // That's the create function input 13 | export type Styles = Partial; 14 | 15 | export type PostConditionStyles = { 16 | [k: ConditionKey]: 17 | | StyleObject 18 | | FlairUpProperties 19 | | Chunks 20 | | PostConditionStyles; 21 | }; 22 | 23 | export type StoredStyles = Record; 24 | 25 | // This is the actual type that's returned from each create function 26 | export type ScopedStyles = Record, ClassSet>; 27 | export type ClassList = (string | undefined)[]; 28 | export {}; 29 | 30 | export type DirectClass = string | string[]; 31 | 32 | type FlairUpProperties = Partial<{ 33 | '.'?: DirectClass; 34 | '--'?: CSSVariablesObject; 35 | }>; 36 | 37 | type Chunks = { 38 | [k: MediaQuery]: 39 | | StyleObject 40 | | Record<'--', CSSVariablesObject> 41 | | PostConditionStyles; 42 | } & { [k: Pseudo]: StyleObject }; 43 | 44 | export type CreateSheetInput = Partial< 45 | { [k in K]: Styles | FlairUpProperties } | PreConditions 46 | >; 47 | 48 | export type PreConditions = { 49 | [k: ConditionKey]: 50 | | { 51 | [k in K]: Styles; 52 | } 53 | | PreConditions; 54 | }; 55 | 56 | type S = Exclude< 57 | K, 58 | ConditionKey | '--' | '.' | keyof CSSStyleDeclaration | Pseudo | MediaQuery 59 | >; 60 | 61 | export type createSheetReturn = { 62 | create: ( 63 | styles: CreateSheetInput, 64 | ) => ScopedStyles> & ScopedStyles; 65 | keyframes: KeyframesFunction; 66 | getStyle: () => string; 67 | isApplied: () => boolean; 68 | }; 69 | 70 | export type KeyframeStages = { 71 | [stage: string]: StyleObject; 72 | }; 73 | 74 | export type keyframesInput = Record; 75 | 76 | export type KeyframesFunction = ( 77 | keyframesInput: keyframesInput, 78 | ) => Record; 79 | 80 | export type KeyframesOutput = Record; 81 | -------------------------------------------------------------------------------- /website/src/examples/CSSVariables.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from 'flairup'; 3 | import { stylesheet } from '../app/stylesheet'; 4 | import { Example } from '../components/Example'; 5 | 6 | const exampleStyle = { 7 | box: { 8 | '--box-bg-color': 'lightgreen', 9 | backgroundColor: 'var(--box-bg-color)', 10 | padding: '15px', 11 | borderRadius: '8px', 12 | margin: '10px 0', 13 | }, 14 | 'box--primary': { 15 | '--box-bg-color': 'lightblue', 16 | }, 17 | 'box--secondary': { 18 | '--box-bg-color': 'lightcoral', 19 | }, 20 | button: { 21 | '--': { 22 | '--button-bg': '#3498db', 23 | '--button-hover-bg': '#2980b9', 24 | '--button-text': 'white', 25 | '--button-padding': '10px 20px', 26 | '--button-radius': '4px', 27 | }, 28 | backgroundColor: 'var(--button-bg)', 29 | color: 'var(--button-text)', 30 | padding: 'var(--button-padding)', 31 | borderRadius: 'var(--button-radius)', 32 | border: 'none', 33 | cursor: 'pointer', 34 | transition: 'background-color 0.3s ease', 35 | ':hover': { 36 | backgroundColor: 'var(--button-hover-bg)', 37 | }, 38 | }, 39 | 'button--danger': { 40 | '--': { 41 | '--button-bg': '#e74c3c', 42 | '--button-hover-bg': '#c0392b', 43 | }, 44 | }, 45 | 'button--success': { 46 | '--': { 47 | '--button-bg': '#2ecc71', 48 | '--button-hover-bg': '#27ae60', 49 | }, 50 | }, 51 | }; 52 | 53 | const styles = stylesheet.create({ 54 | ...exampleStyle, 55 | }); 56 | 57 | export function CSSVariables() { 58 | return ( 59 | 66 |
Default Box
67 |
Primary Box
68 |
Secondary Box
69 | 70 | ); 71 | }`} 72 | > 73 |
Default Box
74 |
Primary Box
75 |
76 | Secondary Box 77 |
78 | 79 |
80 | 81 | 87 | 93 |
94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /website/src/examples/Keyframes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from 'flairup'; 3 | import { stylesheet } from '../app/stylesheet'; 4 | import { Example } from '../components/Example'; 5 | 6 | const keyframes = stylesheet.keyframes({ 7 | bounce: { 8 | '0%': { transform: 'translateY(0)' }, 9 | '50%': { transform: 'translateY(-20px)' }, 10 | '100%': { transform: 'translateY(0)' }, 11 | }, 12 | pulse: { 13 | '0%': { transform: 'scale(1)' }, 14 | '50%': { transform: 'scale(1.2)' }, 15 | '100%': { transform: 'scale(1)' }, 16 | }, 17 | }); 18 | 19 | const exampleStyle = { 20 | box: { 21 | width: '50px', 22 | height: '50px', 23 | backgroundColor: '#3498db', 24 | margin: '10px', 25 | display: 'flex', 26 | justifyContent: 'center', 27 | alignItems: 'center', 28 | fontSize: '30px', 29 | lineHeight: '1', 30 | borderRadius: '10px', 31 | }, 32 | bouncingBox: { 33 | animation: `${keyframes.bounce} 1s infinite`, 34 | }, 35 | pulsingBox: { 36 | animation: `${keyframes.pulse} 1s infinite`, 37 | }, 38 | }; 39 | 40 | const styles = stylesheet.create({ 41 | container: { 42 | display: 'flex', 43 | justifyContent: 'center', 44 | alignItems: 'center', 45 | minHeight: '200px', 46 | }, 47 | ...exampleStyle, 48 | }); 49 | 50 | export function Keyframes() { 51 | return ( 52 | 95 |
🎩
96 |
🎩
97 | 98 | ); 99 | }`} 100 | > 101 |
102 |
🎩
103 |
🎩
104 |
105 |
106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /website/src/examples/ParentClassSupport.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from 'flairup'; 3 | import { stylesheet } from '../app/stylesheet'; 4 | import { Example } from '../components/Example'; 5 | 6 | const exampleStyle = { 7 | '.theme-dark': { 8 | button: { 9 | backgroundColor: '#3498db', 10 | color: 'white', 11 | '&:hover': { 12 | backgroundColor: '#2980b9', 13 | }, 14 | }, 15 | }, 16 | '.theme-light': { 17 | button: { 18 | backgroundColor: '#2ecc71', 19 | color: 'white', 20 | '&:hover': { 21 | backgroundColor: '#27ae60', 22 | }, 23 | }, 24 | }, 25 | }; 26 | 27 | const styles = stylesheet.create({ 28 | themeContainer: { 29 | display: 'flex', 30 | gap: '1em', 31 | marginBottom: '1em', 32 | }, 33 | themeBox: { 34 | padding: '1em', 35 | borderRadius: '8px', 36 | flex: '1', 37 | }, 38 | themeDark: { 39 | backgroundColor: '#2c3e50', 40 | color: '#ecf0f1', 41 | }, 42 | themeLight: { 43 | backgroundColor: '#ecf0f1', 44 | color: 'var(--title-color)', 45 | }, 46 | button: { 47 | padding: '10px 20px', 48 | borderRadius: '5px', 49 | border: 'none', 50 | cursor: 'pointer', 51 | transition: 'all 0.3s ease', 52 | }, 53 | ...exampleStyle, 54 | }); 55 | 56 | export function ParentClassSupport() { 57 | return ( 58 | 62 | FlairUp allows you to scope styles based on a top-level class name 63 | provided when defining your styles. This feature serves two key 64 | purposes: 65 |
    66 |
  • 67 | {`Theme Responsiveness within your Component: By defining styles 68 | under a specific parent class, your component's styles can react 69 | to external theming or global styles applied at a higher level in 70 | the application. For example, you can have different styles for 71 | your component when it resides within a \`.theme-dark\` container.`} 72 |
  • 73 |
  • 74 | {`User Customization: This feature enables users consuming your 75 | component to easily customize its appearance by applying their own 76 | top-level classes. Your component can then define specific styles 77 | that are activated when these user-defined classes are present in 78 | the component's parent hierarchy. This example demonstrates 79 | theme-based styling where button styles change based on the parent 80 | theme class (dark or light). The styles are scoped using the 81 | \`.theme-dark\` and \`.theme-light\` selectors, allowing for 82 | contextual styling`} 83 |
  • 84 |
85 | 86 | } 87 | exampleStyle={exampleStyle} 88 | usage={`function ThemeButtons() { 89 | return ( 90 |
91 |
92 | 93 |
94 |
95 | 96 |
97 |
98 | ); 99 | }`} 100 | > 101 |
102 |
103 | 104 |
105 |
106 | 107 |
108 |
109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /website/src/components/CoreConcepts.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx } from 'flairup'; 3 | import { stylesheet } from '../app/stylesheet'; 4 | import { Section } from './Section'; 5 | import { Code } from './Code'; 6 | 7 | const styles = stylesheet.create({ 8 | coreConcepts: { 9 | display: 'flex', 10 | flexDirection: 'column', 11 | gap: '20px', 12 | marginBottom: '40px', 13 | }, 14 | coreConceptCard: { 15 | backgroundColor: 'var(--card-background)', 16 | padding: '1.5em', 17 | borderRadius: '8px', 18 | boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', 19 | border: '1px solid #eee', 20 | }, 21 | introTitle: { 22 | fontSize: '1.2em', 23 | fontWeight: '600', 24 | color: 'var(--title-color)', 25 | marginBottom: '1em', 26 | display: 'flex', 27 | alignItems: 'center', 28 | '&::before': { 29 | content: '🎩', 30 | marginRight: '0.5em', 31 | }, 32 | }, 33 | introText: { 34 | fontSize: '1.1em', 35 | lineHeight: '1.6', 36 | color: 'var(--card-text)', 37 | }, 38 | }); 39 | 40 | export function CoreConcepts() { 41 | return ( 42 |
43 |
44 |
45 |

The StyleSheet Singleton

46 |

47 | At the heart of FlairUp is the StyleSheet object, which serves as a 48 | singleton for your entire package. This means that all styles across 49 | your library share the same stylesheet instance, allowing for 50 | efficient style deduplication and management. 51 |

52 |

53 | The StyleSheet is created once using the `createSheet` function and 54 | can be used throughout your package. All styles defined using this 55 | stylesheet will be automatically deduplicated, ensuring optimal 56 | performance and minimal CSS output. 57 |

58 | 59 | {`import { createSheet } from 'flairup'; 60 | 61 | // Create a stylesheet for your package 62 | const stylesheet = createSheet('MyPackageName'); 63 | 64 | // Use the stylesheet to create styles 65 | const styles = stylesheet.create({ 66 | button: { 67 | color: 'red', 68 | ':hover': { 69 | color: 'blue', 70 | }, 71 | }, 72 | });`} 73 | 74 |
75 | 76 |
77 |

One Class Per Property

78 |

79 | FlairUp optimizes performance by generating a single class for each 80 | unique CSS property value. This means that if the same style is used 81 | in multiple places, it will only be added to the stylesheet once. 82 |

83 |

84 | For example, if you use the color 'red' in multiple 85 | components, FlairUp will create a single class for it and reuse it 86 | across all instances, reducing the overall CSS bundle size. 87 |

88 |
89 | 90 |
91 |

Style Tag Injection

92 |

93 | FlairUp works by injecting a single {'