├── dist └── .gitkeep ├── .gitignore ├── .github └── workflows │ ├── ellx-sync-test.yml │ └── ellx-sync.yml ├── package.json ├── lib ├── keyframes.js ├── variants.js ├── selector.js ├── parser.js ├── color.js ├── styles.js ├── sheet.js ├── colors.js ├── utilities.js ├── options.js └── config.js ├── README.md ├── index.ellx ├── index.js └── index.md /dist/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .DS_Store 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.github/workflows/ellx-sync-test.yml: -------------------------------------------------------------------------------- 1 | name: Sync repo with test.ellx.io 2 | 3 | on: 4 | push: 5 | branches: 6 | - test 7 | 8 | jobs: 9 | sync: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - uses: ellxoft/ellx-sync@master 14 | with: 15 | ellx-url: https://test-api.ellx.io 16 | github-token: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/ellx-sync.yml: -------------------------------------------------------------------------------- 1 | name: Sync repo with Ellx 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - "release/**" 8 | 9 | jobs: 10 | sync: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v1 14 | - uses: ellxoft/ellx-sync@master 15 | with: 16 | ellx-url: https://api.ellx.io 17 | github-token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "headlong", 3 | "version": "0.1.6", 4 | "description": "Tailwind CSS on the fly", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "build": "esbuild index.js --bundle --minify --format=esm >> dist/index.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/matyunya/headlong.git" 12 | }, 13 | "module": "index.js", 14 | "main": "dist/index.js", 15 | "author": "Maxim Matyunin", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/matyunya/headlong/issues" 19 | }, 20 | "homepage": "https://github.com/matyunya/headlong#readme", 21 | "dependencies": { 22 | "esbuild": "^0.13.14" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/keyframes.js: -------------------------------------------------------------------------------- 1 | // TODO: respect config values (how to calculate missing bits 2 | // if we only get "to { transform: rotate(360deg) }" how to infer "from { transform: rotate(0deg) }"?) 3 | 4 | export default `@keyframes spin { 5 | from { 6 | transform: rotate(0deg); 7 | } 8 | to { 9 | transform: rotate(360deg); 10 | } 11 | } 12 | 13 | @keyframes ping { 14 | 0% { 15 | transform: scale(1); 16 | opacity: 1; 17 | } 18 | 75%, 100% { 19 | transform: scale(2); 20 | opacity: 0; 21 | } 22 | } 23 | 24 | 25 | @keyframes pulse { 26 | 0%, 100% { 27 | opacity: 1; 28 | } 29 | 50% { 30 | opacity: .5; 31 | } 32 | } 33 | 34 | @keyframes bounce { 35 | 0%, 100% { 36 | transform: translateY(-25%); 37 | animation-timing-function: cubic-bezier(0.8, 0, 1, 1); 38 | } 39 | 50% { 40 | transform: translateY(0); 41 | animation-timing-function: cubic-bezier(0, 0, 0.2, 1); 42 | } 43 | }`; 44 | -------------------------------------------------------------------------------- /lib/variants.js: -------------------------------------------------------------------------------- 1 | // TODO: print and other media 2 | function responsiveRuleString(rule) { 3 | if (typeof rule === 'string') { 4 | return `(min-width: ${rule})`; 5 | } 6 | 7 | if (Array.isArray(rule)) { 8 | return rule.map(responsiveRule).join(','); 9 | } 10 | 11 | const rules = []; 12 | if (rule.max) { 13 | rules.push(`(max-width: ${rule.max})`); 14 | } 15 | if (rules.min) { 16 | rules.push(`(min-width: ${rule.max})`); 17 | } 18 | 19 | return rules.join('and'); 20 | } 21 | 22 | const stylePlaceholder = `{ ##style## }`; 23 | export const responsive = rule => (selector, css) => ` @media ${responsiveRuleString(rule)} { 24 | ${selector} ${css ? `{ ${css} }` : stylePlaceholder} 25 | }`; 26 | 27 | // TODO: append dot to className before passing here 28 | export const dark = (selector) => `.mode-dark ${selector}`; 29 | 30 | export const hover = (selector) => `${selector}:hover`; 31 | 32 | export const focus = (selector) => `${selector}:focus`; 33 | 34 | export const active = (selector) => `${selector}:active`; 35 | 36 | export const checked = (selector) => `${selector}:checked`; 37 | 38 | export const groupHover = (selector) => `.group:hover ${selector}`; 39 | 40 | export const groupFocus = (selector) => `.group:focus ${selector}`; 41 | 42 | export const disabled = (selector) => `${selector}[disabled]`; 43 | -------------------------------------------------------------------------------- /lib/selector.js: -------------------------------------------------------------------------------- 1 | export const list = (prop, options) => new RegExp("^" 2 | + prop 3 | + "-(" 4 | + Object.keys(options).map(o => o.replace('.', '\\.')) 5 | .join('|') 6 | + ")$" 7 | ); 8 | 9 | function defaultGetStyle(list, result, propName) { 10 | if (list[result] === undefined) return; 11 | 12 | return `${propName}: ${list[result]};`; 13 | } 14 | 15 | export const match = (name, regex, getStyle = defaultGetStyle, list, className) => { 16 | if (list && list.DEFAULT && name === className) { 17 | return typeof getStyle === "function" 18 | ? getStyle("DEFAULT", list) 19 | : defaultGetStyle(list, "DEFAULT", getStyle); 20 | } 21 | 22 | const [, result, ...rest] = name.match(regex) || []; 23 | 24 | if (result === undefined) return; 25 | 26 | if (typeof getStyle === "string") return defaultGetStyle(list, result, getStyle); 27 | 28 | return getStyle(result, list, rest); 29 | } 30 | 31 | const configUtils = { 32 | negative(scale) { 33 | return Object.keys(scale) 34 | .filter((key) => scale[key] !== '0') 35 | .reduce( 36 | (negativeScale, key) => ({ 37 | ...negativeScale, 38 | [`-${key}`]: `calc(-${scale[key]})`, // find better way 39 | }), 40 | {} 41 | ) 42 | }, 43 | breakpoints(screens) { 44 | return Object.keys(screens) 45 | .filter((key) => typeof screens[key] === 'string') 46 | .reduce( 47 | (breakpoints, key) => ({ 48 | ...breakpoints, 49 | [`screen-${key}`]: screens[key], 50 | }), 51 | {} 52 | ) 53 | }, 54 | }; 55 | 56 | export const theme = configTheme => k => k.split('.').reduce((acc, cur) => acc[cur], configTheme); 57 | 58 | export function getKey(config, key) { 59 | if (typeof config.theme[key] === 'function') { 60 | return config.theme[key](theme(config.theme)); 61 | } 62 | return config.theme[key]; 63 | } 64 | 65 | export const simpleMatch = configTheme => ([className, cssProp, options, appendix]) => 66 | name => { 67 | if (typeof options === 'function') options = options(theme(configTheme), configUtils); 68 | 69 | const res = match(name, list(className, options), cssProp, options, className); 70 | 71 | if (res === undefined) return; 72 | 73 | if (typeof appendix === 'function') appendix = appendix(name); 74 | 75 | return appendix ? res + appendix : res; 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Headlong 2 | 3 | ## Tailwind CSS on the fly without PostCSS 4 | 5 | [README](https://matyunya-headlong.ellx.app/) 6 | 7 | Tailwind CSS produces thousands of classes most of which will never be used. Changes to the Tailwind configuration might take seconds to take effect, and who has seconds to waste these days? There are [articles](https://nystudio107.com/blog/speeding-up-tailwind-css-builds) describing how to speed up Tailwind build times indicating the problem. 8 | 9 | **Headlong** is a runtime version of Tailwind CSS which requires no PostCSS nor purging. Instead of generating all the classes beforehand it adds classes on the fly to the stylesheet whenever they are introduced in the DOM. 10 | 11 | This library is not intended to replace the original Tailwind. Yet, there are environments where one cannot use PostCSS or maybe needs to interpolate class names a lot, or play with configuration. 12 | 13 | Natural advantage of this approach is zero extra build time, _all_ classes are available by default, no need to enable responsive or whatever plugin. 14 | 15 | Headlong was built entirely using [Ellx](https://ellx.io). Here's [source code](https://ellx.io/matyunya/headlong/index.md) and [demo](https://matyunya-headlong.ellx.app/). 16 | 17 | ## Installation and usage 18 | 19 | ``` 20 | $ npm install headlong 21 | ``` 22 | 23 | ```js 24 | import headlong from "headlong"; 25 | 26 | const { 27 | unsubscribe, 28 | parse, 29 | config, 30 | 31 | // returns { styles, classes } of styles string and set of classes 32 | output, 33 | apply, 34 | } = headlong( 35 | config, 36 | { 37 | container, // container element 38 | classes // list of classes to ignore 39 | }); 40 | 41 | // ... 42 | 43 | // stop listening to changes when you're done 44 | unsubscribe(); 45 | ``` 46 | 47 | ## Changelog 48 | 49 | 2021/2/20 50 | - Disallow multiple instances of Headlong on the same page 51 | - Add "output" method 52 | - @apply for simple classes 53 | - Combined selectors 54 | - Fix container 55 | - Add :checked variant 56 | 57 | ## Roadmap 58 | 59 | - [x] Ring 60 | - [x] Divide 61 | - [x] Camelcased colors ("light-blue" is lightBlue in the default palette) 62 | - [x] "Extend" config section 63 | - [x] Preflight 64 | - [x] Container 65 | - [x] Min/max breakpoints, object, array notation breakpoints 66 | - [x] `@apply` as a function (apart from combined variants just like in Tailwind 1.x) 67 | - [x] Combined variants like ("sm:dark:hover:") 68 | - [ ] Negated values using css `calc` function relying on PostCSS plugin 69 | - [ ] Keyframes customization 70 | -------------------------------------------------------------------------------- /lib/parser.js: -------------------------------------------------------------------------------- 1 | import options from "./options.js"; 2 | import utilities, { position, display, container } from "./utilities.js"; 3 | import color from "./color.js"; 4 | 5 | import { 6 | dark, 7 | responsive, 8 | hover, 9 | focus, 10 | active, 11 | groupHover, 12 | groupFocus, 13 | disabled, 14 | checked, 15 | } from "./variants.js"; 16 | 17 | const getFns = (config) => [ 18 | position, 19 | display, 20 | container(config), 21 | ...color(config), 22 | ...utilities(config), 23 | ]; 24 | 25 | // TODO: user-defined variants 26 | const getVariants = config => ({ 27 | ...Object.fromEntries( 28 | Object.keys(config.theme.screens).map(i => [i, responsive(config.theme.screens[i])]) 29 | ), 30 | dark, 31 | hover, 32 | focus, 33 | active, 34 | disabled, 35 | checked, 36 | "group-hover": groupHover, 37 | "group-focus": groupFocus, 38 | }); 39 | 40 | function parseVariant(className, css, variants, sanitize) { 41 | const vars = className 42 | .split(':') 43 | .filter(a => variants[a]) 44 | .sort((a, b) => Object.keys(variants).indexOf(b) - Object.keys(variants).indexOf(a)); 45 | 46 | const selector = vars.reduce((acc, cur) => variants[cur](acc), sanitize('.' + className)); // TODO: mind the "." 47 | 48 | if (selector.includes('##style##')) return selector.replace('##style##', css); 49 | 50 | return `${selector} { ${css} }`; 51 | } 52 | 53 | export const hasVariant = s => /([^:]+:)+[^:]+(::)?[^:]+/.test(s); 54 | 55 | // TODO: refactor for more flexible output 56 | export default function getParse(config) { 57 | const fns = getFns(config); 58 | const variants = getVariants(config); 59 | 60 | function escape(s) { 61 | return s.replace(new RegExp(`(${Object.keys(variants).join('|')}):`, 'g'), '$1\\:'); 62 | } 63 | 64 | function sanitize(name) { 65 | name = name.replace('/', '\\/'); 66 | 67 | if (name.includes("placeholder-")) { 68 | name = escape(name) + "::placeholder"; 69 | } else if (name.includes('space-') || /divide-(?!opacity)/.test(name)) { 70 | name = escape(name) + " > * + *"; 71 | } else { 72 | name = escape(name); 73 | } 74 | 75 | return name; 76 | } 77 | 78 | return (className, onlyStyles = false) => { 79 | const classNameNoVariant = className.split(":").pop(); 80 | let css = options[classNameNoVariant] || fns.reduce((acc, cur) => acc || cur(classNameNoVariant), ""); 81 | 82 | if (!css) return ''; 83 | 84 | if (!css.wrap && !css.endsWith(";")) css += ";"; 85 | 86 | if (hasVariant(className)) { 87 | return parseVariant(className, css, variants, sanitize); 88 | } 89 | 90 | if (css.wrap) return css.wrap(sanitize(className)); 91 | 92 | if (onlyStyles) return css; 93 | 94 | return `.${sanitize(className)} { ${css} }`; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/color.js: -------------------------------------------------------------------------------- 1 | import { getKey } from "./selector.js"; 2 | 3 | const singles = ["current", "transparent", "white", "black"]; 4 | 5 | const toRgb = hex => [ 6 | parseInt(hex.slice(1, 3), 16), 7 | parseInt(hex.slice(3, 5), 16), 8 | parseInt(hex.slice(5, 7), 16) 9 | ].join(','); 10 | 11 | const props = [ 12 | ["placeholder", "placeholderColor", color => `--tw-placeholder-opacity: 1; color: rgba(${color}, var(--tw-placeholder-opacity));`], 13 | ["text", "textColor", color => `--tw-text-opacity: 1; color: rgba(${color}, var(--tw-text-opacity));`], 14 | ["bg", "backgroundColor", color => `--tw-bg-opacity: 1; background-color: rgba(${color}, var(--tw-bg-opacity));`], 15 | ["border", "borderColor", color => `--tw-border-opacity: 1; border-color: rgba(${color}, var(--tw-border-opacity));`], 16 | ["divide", "divideColor", color => `--tw-divide-opacity: 1; border-color: rgba(${color}, var(--tw-divide-opacity));`], 17 | ["ring", "ringColor", color => `--tw-ring-opacity: 1; --tw-ring-color: rgba(${color}, var(--tw-ring-opacity));`], 18 | ["ring-offset", "ringOffsetColor", (_, hex) => `--tw-ring-offset-color: ${hex}; box-shadow: 0 0 0 var(--ring-offset-width) var(--ring-offset-color), var(--ring-shadow);`], 19 | ["from", "gradientColorStops", (color, hex) => `--tw-gradient-from: ${hex}; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, rgba(${color}, 0));`], 20 | ["via", "gradientColorStops", (color, hex) => `--tw-gradient-stops: var(--tw-gradient-from), ${hex}, var(--tw-gradient-to, rgba(${color}, 0));`], 21 | ["to", "gradientColorStops", (_, hex) => `--tw-gradient-to: ${hex};`], 22 | ]; 23 | 24 | const camelize = s => s.replace(/-./g, x=>x.toUpperCase()[1]) 25 | 26 | const kebabize = s => s.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase(); 27 | 28 | const mainCssProps = { 29 | placeholder: "color", 30 | text: "color", 31 | bg: "background-color", 32 | border: "border-color", 33 | divide: "border-color", 34 | ring: "--tw-ring-color", 35 | "ring-offset": i => `--tw-ring-offset-color: ${i}; box-shadow: 0 0 0 var(--ring-offset-width) var(--ring-offset-color), var(--ring-shadow);` 36 | }; 37 | 38 | export default config => { 39 | const regexes = props.reduce((acc, [alias, key]) => ({ 40 | ...acc, 41 | [alias]: new RegExp("^" + alias + "-(" + Object.keys(getKey(config, key)).map(kebabize).join('|') + ")-(\\d00?)$"), 42 | }), {}); 43 | 44 | return props.map(([alias, key, getStyle]) => name => { 45 | const single = singles.find(s => `${alias}-${s}` === name); 46 | const palette = getKey(config, key); 47 | 48 | if (single) { 49 | return typeof mainCssProps[alias] === "string" 50 | ? `${mainCssProps[alias]}: ${palette[single]}` 51 | : mainCssProps[alias](palette[single]); 52 | } 53 | 54 | let [, color, variant] = name.match(regexes[alias]) || []; 55 | 56 | color = color && color.includes('-') ? camelize(color) : color; 57 | 58 | if (!color || !palette[color]) return; 59 | 60 | return getStyle(toRgb(palette[color][variant]), palette[color][variant]); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /lib/styles.js: -------------------------------------------------------------------------------- 1 | import { getKey } from "./selector.js"; 2 | 3 | export const preflight = ` 4 | /*! modern-normalize v1.0.0 | MIT License | https://github.com/sindresorhus/modern-normalize */*,::after,::before{box-sizing:border-box}:root{-moz-tab-size:4;tab-size:4}html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}body{font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'}hr{height:0;color:inherit}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}::-moz-focus-inner{border-style:none;padding:0}:-moz-focusring{outline:1px dotted ButtonText}:-moz-ui-invalid{box-shadow:none}legend{padding:0}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}button{background-color:transparent;background-image:none}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}fieldset{margin:0;padding:0}ol,ul{list-style:none;margin:0;padding:0}html{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";line-height:1.5}body{font-family:inherit;line-height:inherit}*,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}hr{border-top-width:1px}img{border-style:solid}textarea{resize:vertical}input::placeholder,textarea::placeholder{color:#9ca3af}[role=button],button{cursor:pointer}table{border-collapse:collapse}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}button,input,optgroup,select,textarea{padding:0;line-height:inherit;color:inherit}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto} 5 | `; 6 | 7 | export const variables = (config) => { 8 | const ringOffsetColor = getKey(config, "ringOffsetColor").DEFAULT; 9 | const ringColor = getKey(config, "ringColor").DEFAULT; 10 | 11 | return ` 12 | * { 13 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 14 | --tw-ring-offset-width: 0px; 15 | --tw-ring-offset-color: ${ringOffsetColor || '#fff'}; 16 | --tw-ring-color: ${ringColor}; 17 | --tw-ring-offset-shadow: 0 0 #0000; 18 | --tw-ring-shadow: 0 0 #0000; 19 | --tw-shadow: 0 0 #0000; 20 | } 21 | `; 22 | } 23 | -------------------------------------------------------------------------------- /index.ellx: -------------------------------------------------------------------------------- 1 | version: 1.1 2 | nodes: 3 | toggle = () => document.getElementById('test').classList.toggle(className) 4 | button = require("/lib/sheet.js").button 5 | input = require("/lib/sheet.js").input 6 | customConfig = require("/lib/sheet.js").customConfig 7 | $1 = headlong.parse("text-red-500") 8 | $2 = headlong.parse("sm:text-red-500") 9 | $3 = headlong.parse("dark:text-red-500") 10 | $4 = headlong.parse("text-light-blue-500") 11 | $5 = headlong.parse("p-4") 12 | $6 = headlong.parse("m-4") 13 | $7 = headlong.parse("hover:not-italic") 14 | $8 = headlong.parse("animation-pulse") 15 | $13 = headlong.parse("outline-none") 16 | $12 = headlong.parse("container") 17 | $16 = headlong.parse("dark:sm:bg-red-500") 18 | $18 = headlong.parse("sm:dark:bg-red-500") 19 | $15 = headlong.apply(".button", "w-full p-2 dark:sm:bg-red-500 bg-white tracking-wide font-mono dark:from-gray-900 dark:via-gray-800 to-warm-gray-100 dark:to-warm-gray-800 hover:ring-1 ring-0 ring-blue-500 transition duration-150 shadow-sm rounded-sm") 20 | $14 = headlong.output() 21 | $17 = headlong.apply(".test-multiple-variants", "dark:sm:bg-red-500 dark:bg-green-500") 22 | layout: 23 | [ 24 | [, 25 | { 26 | "value": "Toggle:" 27 | } 28 | ], 29 | [, 30 | { 31 | "node": "toggle" 32 | } 33 | ],, 34 | [, 35 | { 36 | "value": "Button:" 37 | } 38 | ], 39 | [, 40 | { 41 | "node": "button" 42 | } 43 | ],, 44 | [, 45 | { 46 | "value": "Input:" 47 | } 48 | ], 49 | [, 50 | { 51 | "node": "input" 52 | } 53 | ],, 54 | [, 55 | { 56 | "value": "Custom config:" 57 | } 58 | ], 59 | [, 60 | { 61 | "node": "customConfig" 62 | } 63 | ],,,,, 64 | [, 65 | { 66 | "value": "T E S T S" 67 | } 68 | ], 69 | [, 70 | { 71 | "value": "Keeping it simple for now, everything must not throw" 72 | } 73 | ],, 74 | [, 75 | { 76 | "node": "$1" 77 | } 78 | ], 79 | [, 80 | { 81 | "node": "$2" 82 | } 83 | ], 84 | [, 85 | { 86 | "node": "$3" 87 | } 88 | ], 89 | [, 90 | { 91 | "node": "$4" 92 | } 93 | ], 94 | [, 95 | { 96 | "node": "$5" 97 | } 98 | ], 99 | [, 100 | { 101 | "node": "$6" 102 | } 103 | ], 104 | [, 105 | { 106 | "node": "$7" 107 | } 108 | ], 109 | [, 110 | { 111 | "node": "$8" 112 | } 113 | ], 114 | [, 115 | { 116 | "node": "$13" 117 | } 118 | ], 119 | [, 120 | { 121 | "node": "$12" 122 | } 123 | ],,, 124 | [, 125 | { 126 | "value": "This is a big one, allows to combine multiple variants: \"dark:sm:text-red-500\" or \"sm:dark:text-red-500\" should be equal" 127 | } 128 | ], 129 | [, 130 | { 131 | "node": "$16" 132 | } 133 | ], 134 | [, 135 | { 136 | "node": "$18" 137 | } 138 | ],, 139 | [, 140 | { 141 | "value": "Output:" 142 | },,,, 143 | { 144 | "value": "Apply:" 145 | } 146 | ], 147 | [,,,,, 148 | { 149 | "node": "$15" 150 | } 151 | ], 152 | [, 153 | { 154 | "node": "$14", 155 | "expansion": { 156 | "vertical": false, 157 | "labelsTop": true, 158 | "secondary": true, 159 | "height": 70, 160 | "width": 2 161 | } 162 | } 163 | ], 164 | [,,,,, 165 | { 166 | "node": "$17" 167 | } 168 | ] 169 | ] 170 | -------------------------------------------------------------------------------- /lib/sheet.js: -------------------------------------------------------------------------------- 1 | import tinycolor from 'tinycolor2'; 2 | 3 | export { default as input } from "~ellx-hub/lib/components/Input/index.js"; 4 | export { default as button } from "~ellx-hub/lib/components/Button/index.js"; 5 | 6 | function multiply(rgb1, rgb2) { 7 | rgb1.b = Math.floor((rgb1.b * rgb2.b) / 255); 8 | rgb1.g = Math.floor((rgb1.g * rgb2.g) / 255); 9 | rgb1.r = Math.floor((rgb1.r * rgb2.r) / 255); 10 | return tinycolor('rgb ' + rgb1.r + ' ' + rgb1.g + ' ' + rgb1.b); 11 | } 12 | 13 | const o = (value, name) => ({ 14 | [name]: tinycolor(value).toHexString() 15 | }); 16 | 17 | const white = tinycolor('#fff'); 18 | 19 | function generatePalette(hex) { 20 | const baseDark = multiply(tinycolor(hex).toRgb(), tinycolor(hex).toRgb()); 21 | const baseTriad = tinycolor(hex).tetrad(); 22 | 23 | const lightest = o(tinycolor.mix(white, hex, 30), '50'); 24 | return { 25 | transLight: tinycolor(lightest[50]) 26 | .toRgbString() 27 | .replace(')', ', 0.2)'), 28 | trans: tinycolor(lightest[50]) 29 | .toRgbString() 30 | .replace(')', ', 0.7)'), 31 | transDark: tinycolor(hex) 32 | .toRgbString() 33 | .replace(')', ', 0.15)'), 34 | ...o(tinycolor.mix(white, hex, 12), '50'), 35 | ...lightest, 36 | ...o(tinycolor.mix(white, hex, 50), '200'), 37 | ...o(tinycolor.mix(white, hex, 70), '300'), 38 | ...o(tinycolor.mix(white, hex, 85), '400'), 39 | ...o(tinycolor.mix(white, hex, 100), '500'), 40 | ...o(tinycolor.mix(baseDark, hex, 87), '600'), 41 | ...o(tinycolor.mix(baseDark, hex, 70), '700'), 42 | ...o(tinycolor.mix(baseDark, hex, 54), '800'), 43 | ...o(tinycolor.mix(baseDark, hex, 25), '900'), 44 | ...o( 45 | tinycolor 46 | .mix(baseDark, baseTriad[4], 15) 47 | .saturate(80) 48 | .lighten(65), 49 | 'a100' 50 | ), 51 | ...o( 52 | tinycolor 53 | .mix(baseDark, baseTriad[4], 15) 54 | .saturate(80) 55 | .lighten(55), 56 | 'a200' 57 | ), 58 | ...o( 59 | tinycolor 60 | .mix(baseDark, baseTriad[4], 15) 61 | .saturate(100) 62 | .lighten(45), 63 | 'a400' 64 | ), 65 | ...o( 66 | tinycolor 67 | .mix(baseDark, baseTriad[4], 15) 68 | .saturate(100) 69 | .lighten(40), 70 | 'a700' 71 | ) 72 | }; 73 | } 74 | 75 | function buildPalette(colors) { 76 | return Object.keys(colors).reduce( 77 | (acc, cur) => ({ 78 | ...acc, 79 | [cur]: generatePalette(colors[cur]) 80 | }), 81 | {} 82 | ); 83 | } 84 | 85 | const colors = { 86 | primary: '#237EB3', 87 | secondary: '#008080', 88 | error: '#f44336', 89 | alert: '#ff9800', 90 | blue: '#2196f3', 91 | dark: '#212121', 92 | }; 93 | 94 | export const customConfig = { 95 | theme: { 96 | extend: { 97 | rotate: { 98 | 360: '360deg', 99 | '-360': '-360deg', 100 | }, 101 | spacing: { 102 | 72: '18rem', 103 | 84: '21rem', 104 | 96: '24rem', 105 | 128: '32rem', 106 | }, 107 | height: { 108 | 96: '20rem', 109 | }, 110 | }, 111 | fontSize: { 112 | '5xl': '6rem', 113 | '4xl': '3.75rem', 114 | '3xl': '3rem', 115 | '2xl': '2.125rem', 116 | xl: '1.5rem', 117 | lg: '1.25rem', 118 | base: '1rem', 119 | sm: '0.875rem', 120 | xs: '0.75rem' 121 | }, 122 | screens: { 123 | sm: { max: '639px' }, 124 | md: '641px', 125 | }, 126 | colors: { 127 | transparent: 'transparent', 128 | 129 | black: '#000', 130 | white: '#fff', 131 | 'white-trans': 'rgba(255,255,255,0.35)', 132 | 'white-transLight': 'rgba(255,255,255,0.2)', 133 | 'white-transDark': 'rgba(255,255,255,0.7)', 134 | 'black-trans': 'rgba(0,0,0,0.35)', 135 | 'black-transLight': 'rgba(0,0,0,0.2)', 136 | 'black-transDark': 'rgba(0,0,0,0.7)', 137 | 138 | gray: { 139 | 50: '#fafafa', 140 | 100: '#f5f5f5', 141 | 200: '#eeeeee', 142 | 300: '#e0e0e0', 143 | 400: '#bdbdbd', 144 | 500: '#9e9e9e', 145 | 600: '#757575', 146 | 700: '#616161', 147 | 800: '#424242', 148 | 900: '#212121', 149 | trans: 'rgba(250, 250, 250, 0.5)', 150 | transLight: 'rgba(250, 250, 250, 0.1)', 151 | transDark: 'rgba(100, 100, 100, 0.2)' 152 | }, 153 | 154 | ...buildPalette(colors) 155 | } 156 | }, 157 | }; 158 | -------------------------------------------------------------------------------- /lib/colors.js: -------------------------------------------------------------------------------- 1 | export default { 2 | transparent: 'transparent', 3 | current: 'currentColor', 4 | black: '#000', 5 | white: '#fff', 6 | rose: { 7 | 50: '#fff1f2', 8 | 100: '#ffe4e6', 9 | 200: '#fecdd3', 10 | 300: '#fda4af', 11 | 400: '#fb7185', 12 | 500: '#f43f5e', 13 | 600: '#e11d48', 14 | 700: '#be123c', 15 | 800: '#9f1239', 16 | 900: '#881337', 17 | }, 18 | pink: { 19 | 50: '#fdf2f8', 20 | 100: '#fce7f3', 21 | 200: '#fbcfe8', 22 | 300: '#f9a8d4', 23 | 400: '#f472b6', 24 | 500: '#ec4899', 25 | 600: '#db2777', 26 | 700: '#be185d', 27 | 800: '#9d174d', 28 | 900: '#831843', 29 | }, 30 | fuchsia: { 31 | 50: '#fdf4ff', 32 | 100: '#fae8ff', 33 | 200: '#f5d0fe', 34 | 300: '#f0abfc', 35 | 400: '#e879f9', 36 | 500: '#d946ef', 37 | 600: '#c026d3', 38 | 700: '#a21caf', 39 | 800: '#86198f', 40 | 900: '#701a75', 41 | }, 42 | purple: { 43 | 50: '#faf5ff', 44 | 100: '#f3e8ff', 45 | 200: '#e9d5ff', 46 | 300: '#d8b4fe', 47 | 400: '#c084fc', 48 | 500: '#a855f7', 49 | 600: '#9333ea', 50 | 700: '#7e22ce', 51 | 800: '#6b21a8', 52 | 900: '#581c87', 53 | }, 54 | violet: { 55 | 50: '#f5f3ff', 56 | 100: '#ede9fe', 57 | 200: '#ddd6fe', 58 | 300: '#c4b5fd', 59 | 400: '#a78bfa', 60 | 500: '#8b5cf6', 61 | 600: '#7c3aed', 62 | 700: '#6d28d9', 63 | 800: '#5b21b6', 64 | 900: '#4c1d95', 65 | }, 66 | indigo: { 67 | 50: '#eef2ff', 68 | 100: '#e0e7ff', 69 | 200: '#c7d2fe', 70 | 300: '#a5b4fc', 71 | 400: '#818cf8', 72 | 500: '#6366f1', 73 | 600: '#4f46e5', 74 | 700: '#4338ca', 75 | 800: '#3730a3', 76 | 900: '#312e81', 77 | }, 78 | blue: { 79 | 50: '#eff6ff', 80 | 100: '#dbeafe', 81 | 200: '#bfdbfe', 82 | 300: '#93c5fd', 83 | 400: '#60a5fa', 84 | 500: '#3b82f6', 85 | 600: '#2563eb', 86 | 700: '#1d4ed8', 87 | 800: '#1e40af', 88 | 900: '#1e3a8a', 89 | }, 90 | lightBlue: { 91 | 50: '#f0f9ff', 92 | 100: '#e0f2fe', 93 | 200: '#bae6fd', 94 | 300: '#7dd3fc', 95 | 400: '#38bdf8', 96 | 500: '#0ea5e9', 97 | 600: '#0284c7', 98 | 700: '#0369a1', 99 | 800: '#075985', 100 | 900: '#0c4a6e', 101 | }, 102 | cyan: { 103 | 50: '#ecfeff', 104 | 100: '#cffafe', 105 | 200: '#a5f3fc', 106 | 300: '#67e8f9', 107 | 400: '#22d3ee', 108 | 500: '#06b6d4', 109 | 600: '#0891b2', 110 | 700: '#0e7490', 111 | 800: '#155e75', 112 | 900: '#164e63', 113 | }, 114 | teal: { 115 | 50: '#f0fdfa', 116 | 100: '#ccfbf1', 117 | 200: '#99f6e4', 118 | 300: '#5eead4', 119 | 400: '#2dd4bf', 120 | 500: '#14b8a6', 121 | 600: '#0d9488', 122 | 700: '#0f766e', 123 | 800: '#115e59', 124 | 900: '#134e4a', 125 | }, 126 | emerald: { 127 | 50: '#ecfdf5', 128 | 100: '#d1fae5', 129 | 200: '#a7f3d0', 130 | 300: '#6ee7b7', 131 | 400: '#34d399', 132 | 500: '#10b981', 133 | 600: '#059669', 134 | 700: '#047857', 135 | 800: '#065f46', 136 | 900: '#064e3b', 137 | }, 138 | green: { 139 | 50: '#f0fdf4', 140 | 100: '#dcfce7', 141 | 200: '#bbf7d0', 142 | 300: '#86efac', 143 | 400: '#4ade80', 144 | 500: '#22c55e', 145 | 600: '#16a34a', 146 | 700: '#15803d', 147 | 800: '#166534', 148 | 900: '#14532d', 149 | }, 150 | lime: { 151 | 50: '#f7fee7', 152 | 100: '#ecfccb', 153 | 200: '#d9f99d', 154 | 300: '#bef264', 155 | 400: '#a3e635', 156 | 500: '#84cc16', 157 | 600: '#65a30d', 158 | 700: '#4d7c0f', 159 | 800: '#3f6212', 160 | 900: '#365314', 161 | }, 162 | yellow: { 163 | 50: '#fefce8', 164 | 100: '#fef9c3', 165 | 200: '#fef08a', 166 | 300: '#fde047', 167 | 400: '#facc15', 168 | 500: '#eab308', 169 | 600: '#ca8a04', 170 | 700: '#a16207', 171 | 800: '#854d0e', 172 | 900: '#713f12', 173 | }, 174 | amber: { 175 | 50: '#fffbeb', 176 | 100: '#fef3c7', 177 | 200: '#fde68a', 178 | 300: '#fcd34d', 179 | 400: '#fbbf24', 180 | 500: '#f59e0b', 181 | 600: '#d97706', 182 | 700: '#b45309', 183 | 800: '#92400e', 184 | 900: '#78350f', 185 | }, 186 | orange: { 187 | 50: '#fff7ed', 188 | 100: '#ffedd5', 189 | 200: '#fed7aa', 190 | 300: '#fdba74', 191 | 400: '#fb923c', 192 | 500: '#f97316', 193 | 600: '#ea580c', 194 | 700: '#c2410c', 195 | 800: '#9a3412', 196 | 900: '#7c2d12', 197 | }, 198 | red: { 199 | 50: '#fef2f2', 200 | 100: '#fee2e2', 201 | 200: '#fecaca', 202 | 300: '#fca5a5', 203 | 400: '#f87171', 204 | 500: '#ef4444', 205 | 600: '#dc2626', 206 | 700: '#b91c1c', 207 | 800: '#991b1b', 208 | 900: '#7f1d1d', 209 | }, 210 | warmGray: { 211 | 50: '#fafaf9', 212 | 100: '#f5f5f4', 213 | 200: '#e7e5e4', 214 | 300: '#d6d3d1', 215 | 400: '#a8a29e', 216 | 500: '#78716c', 217 | 600: '#57534e', 218 | 700: '#44403c', 219 | 800: '#292524', 220 | 900: '#1c1917', 221 | }, 222 | trueGray: { 223 | 50: '#fafafa', 224 | 100: '#f5f5f5', 225 | 200: '#e5e5e5', 226 | 300: '#d4d4d4', 227 | 400: '#a3a3a3', 228 | 500: '#737373', 229 | 600: '#525252', 230 | 700: '#404040', 231 | 800: '#262626', 232 | 900: '#171717', 233 | }, 234 | gray: { 235 | 50: '#fafafa', 236 | 100: '#f4f4f5', 237 | 200: '#e4e4e7', 238 | 300: '#d4d4d8', 239 | 400: '#a1a1aa', 240 | 500: '#71717a', 241 | 600: '#52525b', 242 | 700: '#3f3f46', 243 | 800: '#27272a', 244 | 900: '#18181b', 245 | }, 246 | coolGray: { 247 | 50: '#f9fafb', 248 | 100: '#f3f4f6', 249 | 200: '#e5e7eb', 250 | 300: '#d1d5db', 251 | 400: '#9ca3af', 252 | 500: '#6b7280', 253 | 600: '#4b5563', 254 | 700: '#374151', 255 | 800: '#1f2937', 256 | 900: '#111827', 257 | }, 258 | blueGray: { 259 | 50: '#f8fafc', 260 | 100: '#f1f5f9', 261 | 200: '#e2e8f0', 262 | 300: '#cbd5e1', 263 | 400: '#94a3b8', 264 | 500: '#64748b', 265 | 600: '#475569', 266 | 700: '#334155', 267 | 800: '#1e293b', 268 | 900: '#0f172a', 269 | }, 270 | }; 271 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import getConfig from "./lib/config.js"; 2 | import getParser, { hasVariant } from "./lib/parser.js"; 3 | import keyframes from "./lib/keyframes.js"; 4 | import { preflight as preflightStyles, variables } from "./lib/styles.js"; 5 | import { theme } from "./lib/selector.js"; 6 | 7 | const onObserve = (process, filterClasses, append) => mutations => { 8 | let styles = [...new Set( 9 | mutations 10 | .filter(t => t.type === 'attributes') 11 | .map(i => (i.target.classList.value || "").split(' ')) 12 | .flat() 13 | .filter(a => { 14 | return filterClasses(a) 15 | }) 16 | )] 17 | .map(process) 18 | .filter(Boolean); 19 | 20 | styles = [...styles, ...[...new Set( 21 | mutations 22 | .filter(f => { 23 | return f.type === 'childList' && f.addedNodes.length && f.addedNodes[0].classList 24 | }) 25 | .map(i => { 26 | let c = []; 27 | const node = i.addedNodes[0] 28 | const all = node.getElementsByTagName('*'); 29 | 30 | for (var i = -1, l = all.length; ++i < l;) { 31 | c.push((all[i].classList.value ||'').split(' ')); 32 | } 33 | return [...c.flat(), ...(node.classList.value || '').split(' ')]; 34 | }) 35 | .flat() 36 | ) 37 | ] 38 | .filter(filterClasses) 39 | .map(process) 40 | .filter(Boolean) 41 | ]; 42 | 43 | append(styles); 44 | } 45 | 46 | const getInitialClasses = (process) => [...new Set( 47 | [...document.querySelectorAll("*")] 48 | .map(i => i.classList.value.split(' ')) 49 | .flat() 50 | )] 51 | .map(process) 52 | .filter(Boolean); 53 | 54 | const observeClasses = (observer, container) => observer.observe( 55 | container, 56 | { 57 | subtree: true, 58 | attributes: true, 59 | childList: true, 60 | } 61 | ); 62 | 63 | function mergeUserConfig(config, userConfig) { 64 | if (!userConfig || !userConfig.theme) return config; 65 | 66 | const configTheme = theme(config.theme); 67 | 68 | if (userConfig.theme.extend) { 69 | for (const key in userConfig.theme.extend) { 70 | config.theme[key] = typeof config.theme[key] === 'function' 71 | ? { 72 | ...config.theme[key](configTheme), 73 | ...userConfig.theme.extend[key], 74 | } : { 75 | ...config.theme[key], 76 | ...userConfig.theme.extend[key], 77 | }; 78 | } 79 | } 80 | 81 | return { 82 | ...config, 83 | theme: Object.keys(config.theme).reduce((acc, cur) => ({ 84 | ...acc, 85 | [cur]: userConfig.theme[cur] || config.theme[cur] 86 | }), {}) 87 | }; 88 | } 89 | 90 | function appendCssToEl(css, el) { 91 | if (!css) return; 92 | 93 | if (el.styleSheet) { 94 | el.styleSheet.cssText = css; 95 | } else { 96 | el.appendChild(document.createTextNode(css)); 97 | } 98 | } 99 | 100 | let headlong; 101 | 102 | export function init({ 103 | container = document.querySelector('body'), 104 | classes: userClasses = new Set(), 105 | config: userConfig = {}, 106 | preflight = true, 107 | } = {}) { 108 | if (headlong) return headlong; 109 | 110 | if (!(userClasses instanceof Set)) { 111 | throw new Error('Classes must be instance of Set'); 112 | } 113 | 114 | const classes = new Set(userClasses); 115 | 116 | const s = document.createElement('style'); 117 | s.setAttribute('type', 'text/css'); 118 | document.head.appendChild(s); 119 | 120 | const med = document.createElement('style'); 121 | med.setAttribute('type', 'text/css'); 122 | document.head.appendChild(med); 123 | 124 | function appendStyleMedia(css) { 125 | appendCssToEl(css, med); 126 | } 127 | 128 | function appendStyle(css) { 129 | appendCssToEl(css, s); 130 | } 131 | 132 | function append(styles) { 133 | appendStyle(styles.filter(s => !s.includes('@media')).join('\n')); 134 | appendStyleMedia(styles.filter(s => s.includes('@media')).join('\n')); 135 | } 136 | 137 | const filterClasses = i => Boolean(i) && !classes.has(i) && typeof i === "string"; 138 | 139 | const configMerged = mergeUserConfig(getConfig(), userConfig); 140 | const parse = getParser(configMerged); 141 | 142 | const process = c => { 143 | const css = parse(c); 144 | if (css) { 145 | classes.add(c); 146 | } 147 | return css; 148 | } 149 | 150 | const initialStyles = getInitialClasses(process, filterClasses); 151 | appendStyle( 152 | (preflight ? preflightStyles : "") 153 | + variables(configMerged) 154 | + keyframes 155 | ); 156 | append(initialStyles); 157 | 158 | const classObserver = new MutationObserver(onObserve(process, filterClasses, append)); 159 | observeClasses(classObserver, container); 160 | 161 | function output() { // TODO: what output options do we need? 162 | return { 163 | classes: new Set(classes), 164 | styles: ``.replace(/\n/, '') 165 | }; 166 | } 167 | 168 | headlong = { 169 | unsubscribe: () => { 170 | console.log('Headlong generated styles', output()); 171 | classObserver.disconnect(); 172 | headlong = null; 173 | return output(); 174 | }, 175 | parse, 176 | config: configMerged, 177 | output, 178 | apply: (selector, classList) => { 179 | const arr = classList.split(' '); 180 | const noVariantStyles = arr 181 | .filter(s => !hasVariant(s)) 182 | .map(c => parse(c, true)) 183 | .filter(Boolean) 184 | .join('') || ''; 185 | 186 | appendStyle(`${selector} { ${noVariantStyles} }`); 187 | 188 | // Facing the same problem with @apply variant:class like Tailwind 1.x. 189 | // Got to group all uninque variant groups, capture their styles separately, 190 | // then apply variant groups on selector argument (which can be tricky if we allow arbitrary selector) 191 | // so keeping it simple for now. 192 | 193 | // apply('#mySelector', 'sm:text-red-500 text-green-500 dark:sm:text-yellow-500') 194 | // should resolve to 195 | // #mySelector { --tw-text-opacity: 1; color: rgba(239,68,68, var(--tw-text-opacity)); } 196 | // @media (min-width: 640px) { #mySelector { --tw-text-opacity: 1; color: rgba(239,68,68, var(--tw-text-opacity)); } } 197 | // @media (min-width: 640px) { .mode-dark #mySelector { --tw-text-opacity: 1; color: rgba(245,158,11, var(--tw-text-opacity)); } } 198 | 199 | // const variantStyles = arr.filter(hasVariant).map(c => parse(c).replace(c.split(":").pop(), selector)); 200 | // append(variantStyles); 201 | 202 | return `${selector} { ${noVariantStyles} }`; 203 | } 204 | }; 205 | 206 | return headlong; 207 | } 208 | 209 | export default init; 210 | -------------------------------------------------------------------------------- /lib/utilities.js: -------------------------------------------------------------------------------- 1 | import { simpleMatch } from "./selector.js"; 2 | import keyframes from "./keyframes.js"; 3 | import { responsive } from "./variants.js"; 4 | 5 | export default ({ theme }) => [ 6 | ["text", (i, o) => `font-size: ${o[i][0]}; line-height: ${o[i][1].lineHeight};`, theme.fontSize], 7 | ["ring", (i, o) => `--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(${o[i]} + var(--tw-ring-offset-width)) var(--tw-ring-color);`, theme.ringWidth, `box-shadow: var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 transparent);`], 8 | ["w", "width", theme.width], 9 | ["h", "height", theme.height], 10 | ["max-w", "max-width", theme.maxWidth], 11 | ["max-h", "max-height", theme.maxHeight], 12 | ["min-w", "min-width", theme.minWidth], 13 | ["min-h", "min-height", theme.minHeight], 14 | ["opacity", "opacity", theme.opacity], 15 | ["outline", "outline", theme.outline], 16 | ["leading", "line-height", theme.lineHeight], 17 | ["cursor", "cursor", theme.cursor], 18 | ["tracking", "letter-spacing", theme.letterSpacing], 19 | ["font", "font-weight", theme.fontWeight], 20 | ["z", "z-index", theme.zIndex], 21 | ["stroke", "stroke-width", theme.strokeWidth], 22 | ["grid-cols", "grid-template-columns", theme.gridTemplateColumns], 23 | ["grid-rows", "grid-template-rows", theme.gridTemplateRows], 24 | ["col", "grid-column", theme.gridColumn], 25 | ["col-start", "grid-column-start", theme.gridColumnEnd], 26 | ["col-end", "grid-column-end", theme.gridColumnStart], 27 | ["row", "grid-row", theme.gridRow], 28 | ["row-start", "grid-row-start", theme.gridRowEnd], 29 | ["row-end", "grid-row-end", theme.gridRowStart], 30 | ["auto-cols", "grid-auto-cols", theme.gridAutoColumns], 31 | ["auto-rows", "grid-auto-rows", theme.gridAutoRows], 32 | ["gap", "gap", theme.gap], 33 | ["gap-x", "column-gap", theme.gap], 34 | ["gap-y", "row-gap", theme.gap], 35 | ["shadow", "--tw-shadow", theme.boxShadow, "box-shadow: var(--tw-ring-offset-shadow,0 0 transparent),var(--tw-ring-shadow,0 0 transparent),var(--tw-shadow);"], 36 | ["list", "list-style-type", theme.listStyleType], 37 | ["placeholder-opacity", "--tw-placeholder-opacity", theme.placeholderOpacity], 38 | ["text-opacity", "--tw-text-opacity", theme.textOpacity], 39 | ["bg-opacity", "--tw-bg-opacity", theme.backgroundOpacity], 40 | ["border-opacity", "--tw-border-opacity", theme.borderOpacity], 41 | ["divide-opacity", "--tw-divide-opacity", theme.divideOpacity], 42 | ["ring-opacity", "--tw-ring-opacity", theme.ringOpacity], 43 | ["rotate", "--tw-rotate", theme.rotate], 44 | ["translate-x", "--tw-translate-x", theme.translate], 45 | ["translate-y", "--tw-translate-y", theme.translate], 46 | ["skew-x", "--tw-skew-x", theme.skew], 47 | ["skew-y", "--tw-skew-y", theme.skew], 48 | ["scale-x", "--tw-scale-x", theme.scale], 49 | ["scale-y", "--tw-scale-y", theme.scale], 50 | ["scale", (i, o) => `--tw-scale-x: ${o[i]}; --tw-scale-y: ${o[i]};`, theme.scale], 51 | ["duration", "transition-duration", theme.transitionDuration], 52 | ["ease", "transition-timing-function", theme.transitionTimingFunction], 53 | ["delay", "transition-delay", theme.transitionDelay], 54 | ["ring-offset", "--tw-ring-offset-width", theme.ringOffsetWidth, "box-shadow: 0 0 0 var(--ring-offset-width) var(--ring-offset-color), var(--ring-theme.)"], 55 | ["rounded", "border-radius", theme.borderRadius], 56 | ["rounded-t", (i, o) => `border-top-left-radius: ${o[i]}; border-top-right-radius: ${o[i]};`, theme.borderRadius], 57 | ["rounded-r", (i, o) => `border-top-right-radius: ${o[i]}; border-bottom-right-radius: ${o[i]};`, theme.borderRadius], 58 | ["rounded-b", (i, o) => `border-bottom-right-radius: ${o[i]}; border-bottom-left-radius: ${o[i]};`, theme.borderRadius], 59 | ["rounded-l", (i, o) => `border-top-left-radius: ${o[i]}; border-bottom-left-radius: ${o[i]};`, theme.borderRadius], 60 | ["rounded-tl", "border-top-left-radius", theme.borderRadius], 61 | ["rounded-tr", "border-top-right-radius", theme.borderRadius], 62 | ["rounded-br", "border-bottom-right-radius", theme.borderRadius], 63 | ["rounded-bl", "border-top-left-radius", theme.borderRadius], 64 | ["border", "border-width", theme.borderWidth], 65 | ["border-t", "border-top-width", theme.borderWidth], 66 | ["border-b", "border-bottom-width", theme.borderWidth], 67 | ["border-l", "border-left-width", theme.borderWidth], 68 | ["border-r", "border-right-width", theme.borderWidth], 69 | ["m", "margin", theme.margin], 70 | ["ml", "margin-left", theme.margin], 71 | ["mr", "margin-right", theme.margin], 72 | ["mb", "margin-bottom", theme.margin], 73 | ["mt", "margin-top", theme.margin], 74 | ["my", (i, o) => `margin-top: ${o[i]}; margin-bottom: ${o[i]};`, theme.margin], 75 | ["mx", (i, o) => `margin-left: ${o[i]}; margin-right: ${o[i]};`, theme.margin], 76 | ["p", "padding", theme.padding], 77 | ["pl", "padding-left", theme.padding], 78 | ["pr", "padding-right", theme.padding], 79 | ["pb", "padding-bottom", theme.padding], 80 | ["pt", "padding-top", theme.padding], 81 | ["py", (i, o) => `padding-top: ${o[i]}; padding-bottom: ${o[i]};`, theme.padding], 82 | ["px", (i, o) => `padding-left: ${o[i]}; padding-right: ${o[i]};`, theme.padding], 83 | ["space-y", (i, o) => `--tw-space-y-reverse: 0; margin-top: calc(${o[i]} * calc(1 - var(--tw-space-y-reverse))); margin-bottom: calc(${o[i]} * var(--tw-space-y-reverse));`, theme.space], 84 | ["space-x", (i, o) => `--tw-space-x-reverse: 0; margin-left: calc(${o[i]} * calc(1 - var(--tw-space-x-reverse))); margin-right: calc(${o[i]} * var(--tw-space-x-reverse));`, theme.space], 85 | ["divide-y", (i, o) => `--tw-divide-y-reverse: 0; border-top-width: calc(${o[i]} * calc(1 - var(--tw-divide-y-reverse))); 86 | border-bottom-width: calc(${o[i]} * var(--tw-divide-y-reverse))`, theme.divideWidth], 87 | ["divide-x", (i, o) => `--tw-divide-x-reverse: 0; border-right-width: calc(${o[i]} * var(--tw-divide-x-reverse)); border-left-width: calc(${o[i]} * calc(1 - var(--tw-divide-x-reverse)));`, theme.divideWidth], 88 | ["animation", "animation", theme.animation], 89 | ].map(simpleMatch(theme)); 90 | 91 | export const position = (name) => [ 92 | "static", 93 | "fixed", 94 | "absolute", 95 | "relative", 96 | "sticky", 97 | ].includes(name) ? `position: ${name}` : null; 98 | 99 | export const display = (name) => [ 100 | "block", 101 | "inline-block", 102 | "inline", 103 | "flex", 104 | "inline-flex", 105 | "table", 106 | "table-caption", 107 | "table-cell", 108 | "table-column", 109 | "table-column-group", 110 | "table-footer-group", 111 | "table-header-group", 112 | "table-row-group", 113 | "table-row", 114 | "flow-root", 115 | "grid", 116 | "inline-grid", 117 | "contents", 118 | ].includes(name) ? `display: ${name}` : null; 119 | 120 | 121 | export function container({ theme }) { 122 | const { container, screens } = theme; 123 | 124 | const centered = container.center ? "margin: 0 auto" : ""; 125 | const padded = size => container.padding && container.padding[size] 126 | ? "padding: 0 " + container.padding[size] 127 | : ""; 128 | 129 | const contStyles = `.container { max-width: 100%; ${padded("DEFAULT")} }` 130 | + Object.keys(screens).reduce( 131 | (acc, size) => acc + responsive(screens[size])(".container").replace('##style##', `max-width: ${screens[size]}; ${padded(size)}`), 132 | "", 133 | ); 134 | 135 | console.log({ contStyles }); 136 | 137 | return name => name === 'container' ? { wrap: () => contStyles } : false; 138 | } 139 | -------------------------------------------------------------------------------- /index.md: -------------------------------------------------------------------------------- 1 | # Headlong 2 | 3 | ## Tailwind CSS on the fly without PostCSS 4 | 5 | Tailwind CSS produces thousands of classes most of which will never be used. Changes to the Tailwind configuration might take seconds to take effect, and who has seconds to waste these days? There are [articles](https://nystudio107.com/blog/speeding-up-tailwind-css-builds) describing how to speed up Tailwind build times indicating the problem. 6 | 7 | **Headlong** is a runtime version of Tailwind CSS which requires no PostCSS nor purging. Instead of generating all the classes beforehand it adds classes on the fly to the stylesheet whenever they are introduced in the DOM. 8 | 9 | This library is not intended to replace the original Tailwind. Yet, there are environments where one cannot use PostCSS or maybe needs to interpolate class names a lot, or play with configuration. 10 | 11 | Natural advantage of this approach is zero extra build time, _all_ classes are available by default, no need to enable responsive or whatever plugin. 12 | 13 | Headlong was built entirely using [Ellx](https://ellx.io). Here's [source code](https://ellx.io/matyunya/headlong/index.md) and [demo](https://matyunya-headlong.ellx.app/). 14 | 15 | ## Demo 16 | 17 | Type any utility class name into the input. Click the button to toggle the class on the test div. 18 | 19 | { className = input({ label: "New class name", value: "text-fuchsia-500", size: 4 })} 20 | 21 |