├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── dist ├── index.cjs └── index.mjs ├── package-lock.json ├── package.json ├── src ├── empty.js ├── index.js ├── remove.js ├── rename.js └── transformer.js └── test ├── fixtures ├── basic.css ├── content.css ├── emmet.css ├── empty.css ├── full.css ├── nested.css └── skip.css └── index.test.mjs /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | 84 | # Gatsby files 85 | .cache/ 86 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 87 | # https://nextjs.org/blog/next-9-1#public-directory-support 88 | # public 89 | 90 | # vuepress build output 91 | .vuepress/dist 92 | 93 | # Serverless directories 94 | .serverless/ 95 | 96 | # FuseBox cache 97 | .fusebox/ 98 | 99 | # DynamoDB Local files 100 | .dynamodb/ 101 | 102 | # TernJS port file 103 | .tern-port 104 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | .DS_Store -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC. 4 | 5 | Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). 6 | 7 | #### [v1.1.0](https://github.com/ddamato/html-by-css/compare/v1.0.0...v1.1.0) 8 | 9 | - Clean empty results, tests [`f7fbab4`](https://github.com/ddamato/html-by-css/commit/f7fbab4962ef0a2b4013bb0443f2984b4c5cd805) 10 | - Update README, tests [`a4e53dd`](https://github.com/ddamato/html-by-css/commit/a4e53ddd83d3e69ae5ca0aec10b5e2a3f92bad9c) 11 | 12 | #### v1.0.0 13 | 14 | > 5 March 2023 15 | 16 | - Init project [`138633d`](https://github.com/ddamato/html-by-css/commit/138633d456a2e53f39cf9b8537f72f9907a6b733) 17 | - Housekeeping [`3b5711e`](https://github.com/ddamato/html-by-css/commit/3b5711e66201ccf11a623ccc243fb66bec19e26c) 18 | - Initial commit [`76dc5de`](https://github.com/ddamato/html-by-css/commit/76dc5dee2a6e9b6c0546bb4a7786e4601eb1a972) 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Donnie D'Amato 2 | 3 | Permission to use, copy, modify, and distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # html-by-css 2 | 3 | Generate html by writing css. 4 | 5 | ## Install 6 | 7 | ```sh 8 | npm i html-by-css 9 | ``` 10 | 11 | 12 | ## Usage 13 | 14 | ```js 15 | import generate from 'html-by-css'; 16 | 17 | const source = ` 18 | ul#list { 19 | list-style: none; 20 | margin: 0; 21 | padding: 0; 22 | 23 | & li.item*3 { 24 | padding: .5rem; 25 | 26 | :is(a[href="#"]) { 27 | content: Link; 28 | color: inherit; 29 | } 30 | } 31 | } 32 | `; 33 | 34 | const { html, css } = generate(source); 35 | ``` 36 | 37 | HTML 38 | ```html 39 | 50 | ``` 51 | CSS 52 | ```css 53 | /* legacy: false (default) */ 54 | ul#list { 55 | list-style: none; 56 | margin: 0; 57 | padding: 0; 58 | & li.item { 59 | padding: .5rem; 60 | :is(a[href="#"]) { 61 | color: inherit; 62 | } 63 | } 64 | } 65 | 66 | /* legacy: true */ 67 | ul#list { 68 | list-style: none; 69 | margin: 0; 70 | padding: 0; 71 | } 72 | 73 | ul#list li.item { 74 | padding: .5rem; 75 | } 76 | 77 | ul#list li.item :is(a[href="#"]) { 78 | color: inherit; 79 | } 80 | ``` 81 | 82 | ## How it works 83 | 84 | - Use [`postcss`] to parse and walk through source. 85 | - On target nodes, process data as HTML: 86 | - Find `content` on non-pseudo elements and inject as text. 87 | - Find [`emmet`] syntax and duplicate elements. 88 | - Parse selector using [`css-what`]. 89 | - Transform selector to [`himalaya`] schema. 90 | - Process source as valid CSS: 91 | - Remove `content` on non-pseudo elements. 92 | - Remove [`emmet`] syntax. 93 | - Optionally, use [`postcss-nesting`] plugin to transform into legacy, non-nested CSS. 94 | - Apply additional plugins as provided. 95 | - Return object with `{ html, css }`. 96 | 97 | ## Features 98 | 99 | ### Nesting 100 | The nesting should be prepared using [the current w3 CSS Nesting specification](https://www.w3.org/TR/css-nesting-1/). The most important concept is that a nested selector **must start with a symbol**. 101 | 102 | ```css 103 | .foo { 104 | /* ❌ invalid */ 105 | span { 106 | color: hotpink; 107 | } 108 | 109 | /* ✅ valid */ 110 | & span { 111 | color: hotpink; 112 | } 113 | 114 | /* ❌ invalid */ 115 | span & { 116 | color: hotpink; 117 | } 118 | 119 | /* ✅ valid */ 120 | :is(span) & { 121 | color: hotpink; 122 | } 123 | } 124 | ``` 125 | 126 | If you wish to collapse the nesting for the CSS output, set `legacy: true` in the options. This uses [`postcss-nesting`] with the default options. 127 | 128 | ```js 129 | const { html, css } = generate(source, { legacy: true }); 130 | ``` 131 | 132 | If you want to set your own options, provide your own version of the [`postcss-nesting`] plugin and configure. 133 | 134 | ```js 135 | import nesting from 'postcss-nesting'; 136 | import generate from 'html-by-css'; 137 | 138 | const postcssPlugins = [nesting({ 139 | noIsPseudoSelector: true 140 | })]; 141 | const { html, css } = generate(source, { plugins: postcssPlugins }); 142 | ``` 143 | 144 | > **Warning** 145 | > 146 | > Do not use `legacy: true` with your own [`postcss-nesting`] configuration. The internal (`legacy`) usage will run first. Either do not declare or explicitly set `legacy: false`. This is only when using a custom [`postcss-nesting`], all other plugins can be used with `legacy: true`. 147 | 148 | ### PostCSS plugins 149 | 150 | You can include additional [`postcss`] plugins. Example below helps with [removing duplicate declarations](https://www.npmjs.com/package/postcss-discard-duplicates). 151 | 152 | ```js 153 | import dedupe from 'postcss-discard-duplicates'; 154 | import generate from 'html-by-css'; 155 | 156 | const postcssPlugins = [dedupe()]; 157 | const { html, css } = generate(source, { plugins: postcssPlugins }); 158 | ``` 159 | 160 | This is helpful if you have several similar elements with different contents. 161 | 162 | ```css 163 | ul#list { 164 | & li.item { 165 | & a*0 { 166 | color: inherit; 167 | } 168 | 169 | & a[href="/home"] { 170 | content: Home; 171 | } 172 | 173 | & a[href="/about"] { 174 | content: About; 175 | } 176 | 177 | & a[href="/contact"] { 178 | content: Contact 179 | } 180 | } 181 | } 182 | ``` 183 | 184 | > **Note** 185 | > 186 | > The use of `a*0` says _write the styles found here, but don't write HTML_. When you use this, the represented node and it's children will not be written as HTML. 187 | 188 | ### Duplicate nodes 189 | 190 | To create multiple elements, use [`emmet`] syntax. 191 | 192 | ```css 193 | ul { 194 | li*5 { 195 | /* Makes 5
  • elements */ 196 | } 197 | } 198 | ``` 199 | This `li*5` selector is not valid CSS and is transformed during processing to `li`. 200 | ### Text content 201 | 202 | To add text content, use the `content` property on non-pseudo elements. 203 | 204 | ```css 205 | main { 206 | & h1 { 207 | content: Hello world!; 208 | } 209 | } 210 | ``` 211 | 212 | ```html 213 |
    214 |

    Hello world!

    215 |
    216 | ``` 217 | 218 | > **Note** 219 | > 220 | > There are no quotes around the string. Adding quotes would _include the quotes_ in the output. 221 | 222 | The `content` property is not valid on non-pseudo elements and is removed from these declarations during processing. 223 | 224 | ## Testing 225 | 226 | ```sh 227 | npm t 228 | ``` 229 | 230 | - Using [`cheerio`] to traverse HTML in tests. 231 | - Using [`@projectwallace/css-analyzer`] to analyze returned CSS. 232 | 233 | There's definitely some cases not covered in the tests yet. 234 | 235 | - [ ] `content` prop and nested selector (both text and children). 236 | - [ ] Other pseudo-selectors (`:nth-child()`, `:checked`). 237 | 238 | ## Why 239 | 240 | > Your scientists were so preoccupied with whether they could, they didn't stop to think if they should. 241 | 242 | There's a few projects out there that are HTML preprocessors ([Haml](https://haml.info/), [Pug](https://pugjs.org/api/getting-started.html)) which have their own (sometimes CSS-like) syntax. I wondered if we could get closer to just writing CSS to produce HTML. With the new nesting specification and the power of [`postcss`], it looks like we can! 243 | 244 | [`@projectwallace/css-analyzer`]: (https://www.npmjs.com/package/@projectwallace/css-analyzer) 245 | [`cheerio`]: (https://www.npmjs.com/package/cheerio); 246 | [`css-what`]: (https://www.npmjs.com/package/css-what) 247 | [`emmet`]: (https://docs.emmet.io) 248 | [`himalaya`]: (https://www.npmjs.com/package/himalaya) 249 | [`postcss`]: (https://www.npmjs.com/package/postcss) 250 | [`postcss-nesting`]: (https://www.npmjs.com/package/postcss-nesting) -------------------------------------------------------------------------------- /dist/index.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const require$$0$1 = require('postcss'); 4 | const require$$1 = require('postcss-nesting'); 5 | const require$$2 = require('himalaya'); 6 | const require$$0 = require('css-what'); 7 | 8 | const { parse } = require$$0; 9 | function getTagName(result) { 10 | const tree = [].concat(result).flat().filter(Boolean); 11 | const tag = tree.find(({ type }) => type === "tag"); 12 | if (!tag) { 13 | const pseudo = tree.find(({ type }) => type === "pseudo"); 14 | return pseudo ? getTagName(pseudo?.data) : "div"; 15 | } 16 | return tag.name; 17 | } 18 | function getAttributes(result) { 19 | const tree = [].concat(result).flat().filter(Boolean); 20 | const attr = tree.filter(({ type }) => type === "attribute").map(({ name, value }) => { 21 | return { key: name, value }; 22 | }); 23 | const pseudos = tree.filter(({ type }) => type === "pseudo").map((pseudo) => { 24 | return getAttributes(pseudo?.data); 25 | }); 26 | return attr.concat(pseudos).flat().reduce((acc, { key, value }) => { 27 | const target = acc.find((attr2) => attr2.key === key); 28 | if (!target) 29 | return acc.concat({ key, value }); 30 | target.value = [target.value, value].filter(Boolean).join(" ").trim(); 31 | return acc; 32 | }, []); 33 | } 34 | function transformer$1(selector) { 35 | const result = parse(selector.replace("&", "")).flat(); 36 | const entry = { 37 | type: "element", 38 | tagName: getTagName(result), 39 | attributes: getAttributes(result), 40 | children: [] 41 | }; 42 | return entry; 43 | } 44 | var transformer_1 = transformer$1; 45 | 46 | function rename$1(options) { 47 | const { 48 | replace = (r) => r 49 | } = options; 50 | return (tree) => { 51 | tree.walkRules((rule) => { 52 | rule.selector = replace(rule.selector); 53 | }); 54 | }; 55 | } 56 | var rename_1 = rename$1; 57 | 58 | function remove$1(options) { 59 | const { 60 | filter = Function.prototype, 61 | property = null 62 | } = options; 63 | return (tree) => { 64 | tree.walkRules((rule) => { 65 | rule.walkDecls((decl) => { 66 | filter(decl.parent.selector) && [].concat(property).includes(decl.prop) && decl.remove(); 67 | }); 68 | }); 69 | }; 70 | } 71 | var remove_1 = remove$1; 72 | 73 | function empty$1() { 74 | return (tree) => tree.walkRules((rule) => ancestors(rule)); 75 | } 76 | function ancestors(rule) { 77 | const { parent } = rule; 78 | if (rule.nodes.length) 79 | return; 80 | rule.remove(); 81 | parent && ancestors(parent); 82 | } 83 | var empty_1 = empty$1; 84 | 85 | const postcss = require$$0$1; 86 | const postcssNesting = require$$1; 87 | const { stringify } = require$$2; 88 | const transformer = transformer_1; 89 | const rename = rename_1; 90 | const remove = remove_1; 91 | const empty = empty_1; 92 | const DEFAULT_PLUGINS = [ 93 | // Removing the emmet syntax 94 | rename({ replace: (raw) => raw.replace(/\*\d+$/, "") }), 95 | // Remove content on non-pseudos 96 | remove({ property: "content", filter: (sel) => !/(.*::?)(after|before)/.test(sel) }), 97 | // Remove empty declarations 98 | empty() 99 | ]; 100 | function parseMultiplier(selector) { 101 | const { groups } = selector.match(/[^*]+\*(?\d+)/, "gmisu") || {}; 102 | return groups ? Number(groups.multi) : 1; 103 | } 104 | function walk(node, results) { 105 | node.each((child) => { 106 | if (child.type === "decl" && child.prop === "content" && !/:before|:after/.test(child.parent.selector)) { 107 | results.push({ 108 | type: "text", 109 | content: child.value 110 | }); 111 | child.remove(); 112 | } 113 | if (child.selector) { 114 | Array.from({ length: parseMultiplier(child.selector) }, () => { 115 | const entry = transformer(child.selector); 116 | results.push(entry); 117 | if (child?.nodes?.length) { 118 | return walk(child, entry.children); 119 | } 120 | }); 121 | } 122 | return results; 123 | }); 124 | return results; 125 | } 126 | function htmlByCss(source, options) { 127 | const { 128 | legacy, 129 | plugins 130 | } = { legacy: false, plugins: [], ...options }; 131 | const _plugins = [...DEFAULT_PLUGINS]; 132 | if (legacy) { 133 | _plugins.push(postcssNesting()); 134 | } 135 | const parsed = postcss.parse(source); 136 | const html = stringify(walk(parsed, [])); 137 | const { css } = postcss(_plugins.concat(plugins)).process(source, { from: void 0 }); 138 | return { html, css }; 139 | } 140 | var src = htmlByCss; 141 | 142 | module.exports = src; 143 | -------------------------------------------------------------------------------- /dist/index.mjs: -------------------------------------------------------------------------------- 1 | import require$$0$1 from 'postcss'; 2 | import require$$1 from 'postcss-nesting'; 3 | import require$$2 from 'himalaya'; 4 | import require$$0 from 'css-what'; 5 | 6 | const { parse } = require$$0; 7 | function getTagName(result) { 8 | const tree = [].concat(result).flat().filter(Boolean); 9 | const tag = tree.find(({ type }) => type === "tag"); 10 | if (!tag) { 11 | const pseudo = tree.find(({ type }) => type === "pseudo"); 12 | return pseudo ? getTagName(pseudo?.data) : "div"; 13 | } 14 | return tag.name; 15 | } 16 | function getAttributes(result) { 17 | const tree = [].concat(result).flat().filter(Boolean); 18 | const attr = tree.filter(({ type }) => type === "attribute").map(({ name, value }) => { 19 | return { key: name, value }; 20 | }); 21 | const pseudos = tree.filter(({ type }) => type === "pseudo").map((pseudo) => { 22 | return getAttributes(pseudo?.data); 23 | }); 24 | return attr.concat(pseudos).flat().reduce((acc, { key, value }) => { 25 | const target = acc.find((attr2) => attr2.key === key); 26 | if (!target) 27 | return acc.concat({ key, value }); 28 | target.value = [target.value, value].filter(Boolean).join(" ").trim(); 29 | return acc; 30 | }, []); 31 | } 32 | function transformer$1(selector) { 33 | const result = parse(selector.replace("&", "")).flat(); 34 | const entry = { 35 | type: "element", 36 | tagName: getTagName(result), 37 | attributes: getAttributes(result), 38 | children: [] 39 | }; 40 | return entry; 41 | } 42 | var transformer_1 = transformer$1; 43 | 44 | function rename$1(options) { 45 | const { 46 | replace = (r) => r 47 | } = options; 48 | return (tree) => { 49 | tree.walkRules((rule) => { 50 | rule.selector = replace(rule.selector); 51 | }); 52 | }; 53 | } 54 | var rename_1 = rename$1; 55 | 56 | function remove$1(options) { 57 | const { 58 | filter = Function.prototype, 59 | property = null 60 | } = options; 61 | return (tree) => { 62 | tree.walkRules((rule) => { 63 | rule.walkDecls((decl) => { 64 | filter(decl.parent.selector) && [].concat(property).includes(decl.prop) && decl.remove(); 65 | }); 66 | }); 67 | }; 68 | } 69 | var remove_1 = remove$1; 70 | 71 | function empty$1() { 72 | return (tree) => tree.walkRules((rule) => ancestors(rule)); 73 | } 74 | function ancestors(rule) { 75 | const { parent } = rule; 76 | if (rule.nodes.length) 77 | return; 78 | rule.remove(); 79 | parent && ancestors(parent); 80 | } 81 | var empty_1 = empty$1; 82 | 83 | const postcss = require$$0$1; 84 | const postcssNesting = require$$1; 85 | const { stringify } = require$$2; 86 | const transformer = transformer_1; 87 | const rename = rename_1; 88 | const remove = remove_1; 89 | const empty = empty_1; 90 | const DEFAULT_PLUGINS = [ 91 | // Removing the emmet syntax 92 | rename({ replace: (raw) => raw.replace(/\*\d+$/, "") }), 93 | // Remove content on non-pseudos 94 | remove({ property: "content", filter: (sel) => !/(.*::?)(after|before)/.test(sel) }), 95 | // Remove empty declarations 96 | empty() 97 | ]; 98 | function parseMultiplier(selector) { 99 | const { groups } = selector.match(/[^*]+\*(?\d+)/, "gmisu") || {}; 100 | return groups ? Number(groups.multi) : 1; 101 | } 102 | function walk(node, results) { 103 | node.each((child) => { 104 | if (child.type === "decl" && child.prop === "content" && !/:before|:after/.test(child.parent.selector)) { 105 | results.push({ 106 | type: "text", 107 | content: child.value 108 | }); 109 | child.remove(); 110 | } 111 | if (child.selector) { 112 | Array.from({ length: parseMultiplier(child.selector) }, () => { 113 | const entry = transformer(child.selector); 114 | results.push(entry); 115 | if (child?.nodes?.length) { 116 | return walk(child, entry.children); 117 | } 118 | }); 119 | } 120 | return results; 121 | }); 122 | return results; 123 | } 124 | function htmlByCss(source, options) { 125 | const { 126 | legacy, 127 | plugins 128 | } = { legacy: false, plugins: [], ...options }; 129 | const _plugins = [...DEFAULT_PLUGINS]; 130 | if (legacy) { 131 | _plugins.push(postcssNesting()); 132 | } 133 | const parsed = postcss.parse(source); 134 | const html = stringify(walk(parsed, [])); 135 | const { css } = postcss(_plugins.concat(plugins)).process(source, { from: void 0 }); 136 | return { html, css }; 137 | } 138 | var src = htmlByCss; 139 | 140 | export { src as default }; 141 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "html-by-css", 3 | "version": "1.1.0", 4 | "description": "Generate html by writing css", 5 | "main": "./dist/index.cjs", 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.mjs", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "files": [ 14 | "dist" 15 | ], 16 | "scripts": { 17 | "build": "npm run clean && unbuild", 18 | "clean": "rm -rf ./dist", 19 | "test": "npm run build && mocha", 20 | "prepublishOnly": "npm test", 21 | "version": "auto-changelog -p && git add CHANGELOG.md" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/ddamato/html-by-css.git" 26 | }, 27 | "keywords": [ 28 | "html", 29 | "css", 30 | "generate", 31 | "parse", 32 | "nest", 33 | "nesting" 34 | ], 35 | "author": "Donnie D'Amato ", 36 | "license": "ISC", 37 | "bugs": { 38 | "url": "https://github.com/ddamato/html-by-css/issues" 39 | }, 40 | "homepage": "https://github.com/ddamato/html-by-css#readme", 41 | "dependencies": { 42 | "css-what": "^6.1.0", 43 | "himalaya": "^1.1.0", 44 | "postcss": "^8.4", 45 | "postcss-nesting": "^11.2.1" 46 | }, 47 | "devDependencies": { 48 | "@projectwallace/css-analyzer": "^5.8.0", 49 | "auto-changelog": "^2.4.0", 50 | "chai": "^4.3.7", 51 | "cheerio": "^1.0.0-rc.12", 52 | "mocha": "^10.2.0", 53 | "unbuild": "^1.1.2" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/empty.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes empty declarations 3 | * 4 | * @returns {Function} - PostCSS plugin 5 | */ 6 | function empty() { 7 | return (tree) => tree.walkRules((rule) => ancestors(rule)); 8 | } 9 | 10 | /** 11 | * Recursively traverses up node tree to remove newly empty nodes. 12 | * 13 | * @param {Node} rule - PostCSS tree node 14 | * @returns {Undefined} 15 | */ 16 | function ancestors(rule) { 17 | const { parent } = rule; 18 | if (rule.nodes.length) return; 19 | rule.remove(); 20 | parent && ancestors(parent); 21 | } 22 | 23 | module.exports = empty; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const postcss = require('postcss'); 2 | const postcssNesting = require('postcss-nesting'); 3 | const { stringify } = require('himalaya'); 4 | const transformer = require('./transformer.js'); 5 | const rename = require('./rename.js'); 6 | const remove = require('./remove.js'); 7 | const empty = require('./empty.js'); 8 | 9 | const DEFAULT_PLUGINS = [ 10 | // Removing the emmet syntax 11 | rename({ replace: (raw) => raw.replace(/\*\d+$/, '') }), 12 | // Remove content on non-pseudos 13 | remove({ property: 'content', filter: (sel) => !/(.*::?)(after|before)/.test(sel) }), 14 | // Remove empty declarations 15 | empty() 16 | ]; 17 | 18 | /** 19 | * Determine the number of nodes to generate using emmet selector syntax 20 | * 21 | * @example 'li.item*5' => 5 22 | * @param {String} selector - CSS Selector which may include emmet notation 23 | * @returns {Number} - Number of nodes to generate 24 | */ 25 | function parseMultiplier(selector) { 26 | const { groups } = (selector.match(/[^*]+\*(?\d+)/, 'gmisu') || {}); 27 | return groups ? Number(groups.multi) : 1; 28 | } 29 | 30 | /** 31 | * Creates a tree of nodes using himalaya schema 32 | * 33 | * @param {Node} node - PostCSS node 34 | * @param {Array} results - Tree of node entries 35 | * @returns {Array} - Tree of node entries based on himalaya schema 36 | */ 37 | function walk(node, results) { 38 | node.each((child) => { 39 | if (child.type === 'decl' 40 | && child.prop === 'content' 41 | && !/:before|:after/.test(child.parent.selector) 42 | ) { 43 | results.push({ 44 | type: 'text', 45 | content: child.value 46 | }); 47 | child.remove(); 48 | } 49 | 50 | if (child.selector) { 51 | Array.from({ length: parseMultiplier(child.selector) }, () => { 52 | const entry = transformer(child.selector); 53 | results.push(entry); 54 | 55 | if (child?.nodes?.length) { 56 | return walk(child, entry.children); 57 | } 58 | }); 59 | } 60 | return results; 61 | }); 62 | return results; 63 | } 64 | 65 | /** 66 | * Generate HTML and CSS from source css using nesting syntax 67 | * 68 | * @param {String} source - CSS to be processed 69 | * @param {Object} [options] - Configuration object 70 | * @param {Boolean} [options.legacy] - If true, includes the postcss-nesting plugin to coerce nesting syntax into legacy CSS. 71 | * @param {Array} [options.plugins] - Array of additional plugins to adjust resulting CSS. Does not affect HTML. 72 | * @returns {Object} - { html, css } 73 | */ 74 | function htmlByCss(source, options) { 75 | const { 76 | legacy, 77 | plugins 78 | } = { legacy: false, plugins: [], ...options }; 79 | 80 | const _plugins = [...DEFAULT_PLUGINS]; 81 | if (legacy) { 82 | _plugins.push(postcssNesting()); 83 | } 84 | 85 | const parsed = postcss.parse(source); 86 | const html = stringify(walk(parsed, [])); 87 | const { css } = postcss(_plugins.concat(plugins)).process(source, { from: undefined }); 88 | return { html, css }; 89 | } 90 | 91 | module.exports = htmlByCss; -------------------------------------------------------------------------------- /src/remove.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Allows for incoming selector to have properties removed. 3 | * 4 | * @param {Object} options - Configuration object 5 | * @param {Function} options.filter - Filter by incoming selector 6 | * @param {String|Array} options.property - Property to be removed on filtered selectors 7 | * @returns {Function} - PostCSS plugin 8 | */ 9 | function remove(options) { 10 | const { 11 | filter = Function.prototype, 12 | property = null, 13 | } = options; 14 | 15 | return (tree) => { 16 | tree.walkRules((rule) => { 17 | rule.walkDecls((decl) => { 18 | filter(decl.parent.selector) 19 | && [].concat(property).includes(decl.prop) 20 | && decl.remove(); 21 | }); 22 | }); 23 | } 24 | } 25 | 26 | module.exports = remove; -------------------------------------------------------------------------------- /src/rename.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Allows for incoming selector to be renamed. 3 | * 4 | * @param {Object} options - Configuration object 5 | * @param {Function} options.replace - Incoming selector, return new selector 6 | * @returns {Function} - PostCSS plugin 7 | */ 8 | function rename(options) { 9 | const { 10 | replace = (r) => r, 11 | } = options; 12 | 13 | return (tree) => { 14 | tree.walkRules((rule) => { 15 | rule.selector = replace(rule.selector); 16 | }); 17 | } 18 | } 19 | 20 | module.exports = rename; -------------------------------------------------------------------------------- /src/transformer.js: -------------------------------------------------------------------------------- 1 | const { parse } = require('css-what'); 2 | 3 | function getTagName(result) { 4 | const tree = [].concat(result).flat().filter(Boolean); 5 | const tag = tree.find(({ type }) => type === 'tag'); 6 | if (!tag) { 7 | const pseudo = tree.find(({ type }) => type === 'pseudo'); 8 | return pseudo ? getTagName(pseudo?.data) : 'div'; 9 | } 10 | return tag.name; 11 | } 12 | 13 | function getAttributes(result) { 14 | const tree = [].concat(result).flat().filter(Boolean); 15 | const attr = tree.filter(({ type }) => type === 'attribute').map(({ name, value }) => { 16 | return { key: name, value } 17 | }); 18 | const pseudos = tree.filter(({ type }) => type === 'pseudo').map((pseudo) => { 19 | return getAttributes(pseudo?.data); 20 | }); 21 | return attr.concat(pseudos).flat().reduce((acc, { key, value }) => { 22 | const target = acc.find((attr) => attr.key === key); 23 | if (!target) return acc.concat({ key, value }); 24 | target.value = [target.value, value].filter(Boolean).join(' ').trim(); 25 | return acc; 26 | }, []); 27 | } 28 | 29 | function transformer (selector) { 30 | const result = parse(selector.replace('&', '')).flat(); 31 | 32 | const entry = { 33 | type: 'element', 34 | tagName: getTagName(result), 35 | attributes: getAttributes(result), 36 | children: [] 37 | } 38 | 39 | return entry; 40 | } 41 | 42 | module.exports = transformer; -------------------------------------------------------------------------------- /test/fixtures/basic.css: -------------------------------------------------------------------------------- 1 | main { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | } -------------------------------------------------------------------------------- /test/fixtures/content.css: -------------------------------------------------------------------------------- 1 | main { 2 | padding: 10vw; 3 | &:before { 4 | content: ''; 5 | display: block; 6 | } 7 | & h1 { 8 | content: Hello world!; 9 | font-size: 2rem; 10 | } 11 | } -------------------------------------------------------------------------------- /test/fixtures/emmet.css: -------------------------------------------------------------------------------- 1 | ul { 2 | list-style: none; 3 | margin: 0; 4 | padding: 0; 5 | display: flex; 6 | gap: 1rem; 7 | & li.item*5 { 8 | padding: .5rem; 9 | & a[href="#"] { 10 | color: inherit; 11 | font-size: 1.2em; 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /test/fixtures/empty.css: -------------------------------------------------------------------------------- 1 | ul#list { 2 | & li.item*3 { 3 | a[href="#"] { 4 | content: Only HTML; 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /test/fixtures/full.css: -------------------------------------------------------------------------------- 1 | ul#list { 2 | background: var(--background-color); 3 | list-style: none; 4 | & li.item*5 { 5 | margin-block: calc(100% - 5rem); 6 | :is(a[href="#"]) { 7 | border: var(--border-width) solid rgba(255, 100, 50, .4); 8 | } 9 | 10 | .btn.primary[style="color: red; display: flex;"] { 11 | padding: 16px; 12 | } 13 | 14 | :where(button.some-class):not(.secondary) { 15 | overflow: hidden; 16 | } 17 | 18 | #special-thing:before { 19 | /* This declaration left intentionally blank */ 20 | } 21 | } 22 | 23 | @media (prefers-color-scheme: dark) { 24 | color: cyan; 25 | } 26 | } -------------------------------------------------------------------------------- /test/fixtures/nested.css: -------------------------------------------------------------------------------- 1 | ul { 2 | list-style: none; 3 | margin: 0; 4 | padding: 0; 5 | & li.item { 6 | :is(a[href="#"]) { 7 | color: inherit; 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/skip.css: -------------------------------------------------------------------------------- 1 | ul { 2 | list-style: none; 3 | margin: 0; 4 | padding: 0; 5 | display: flex; 6 | gap: 1rem; 7 | & li.item { 8 | padding: .5rem; 9 | & a*0 { 10 | color: inherit; 11 | font-size: 1.2em; 12 | } 13 | 14 | & a[href="/home"] { 15 | content: Home; 16 | } 17 | 18 | & a[href="/about"] { 19 | content: About; 20 | } 21 | 22 | & a[href="/contact"] { 23 | content: Contact; 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /test/index.test.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { resolve } from 'path'; 3 | import { expect } from 'chai'; 4 | import { load } from 'cheerio'; 5 | import { analyze } from '@projectwallace/css-analyzer'; 6 | 7 | import htmlByCss from '../dist/index.mjs'; 8 | 9 | function read(fixturePath) { 10 | return readFileSync(resolve('test', 'fixtures', fixturePath), 'utf8'); 11 | } 12 | 13 | function fixture(path, options = { legacy: true }) { 14 | const { html, css } = htmlByCss(read(path), options); 15 | const _css = analyze(css); 16 | _css.toString = function () { return css }; 17 | return { 18 | html: load(html, null, false), 19 | css: _css, 20 | }; 21 | } 22 | 23 | describe('html-by-css', function () { 24 | it('should be a function', function () { 25 | expect(htmlByCss).to.be.a('function'); 26 | }); 27 | 28 | it('should generate strings', function () { 29 | const source = read('basic.css'); 30 | const { html, css } = htmlByCss(source); 31 | expect(html).to.be.a('string'); 32 | expect(css).to.be.a('string'); 33 | }); 34 | 35 | describe('html', function () { 36 | it('should generate valid HTML from single selector', function (){ 37 | const { html: $ } = fixture('basic.css'); 38 | console.log($.html()); 39 | const { name } = $('main').get(0); 40 | expect(name).to.equal('main'); 41 | }); 42 | 43 | it('should generate valid HTML from nested selectors', function () { 44 | const { html: $ } = fixture('nested.css'); 45 | console.log($.html()); 46 | const li = $('ul').children(); 47 | expect(li.length).to.equal(1); 48 | const { name, attributes } = $('a').parent().get(0); 49 | expect(name).to.equal('li'); 50 | const [className] = attributes; 51 | expect(className.value).to.equal('item'); 52 | }); 53 | 54 | it('should generate additional nodes using emmet', function () { 55 | const { html: $ } = fixture('emmet.css'); 56 | console.log($.html()); 57 | const li = $('ul').find('li'); 58 | expect(li.length).to.equal(5); 59 | const hrefs = li.map(function() { 60 | return $(this).children().first().attr('href') 61 | }).get(); 62 | expect(hrefs.join('')).to.equal('#####'); 63 | }); 64 | 65 | it('should skip HTML rendering using *0', function () { 66 | const { html: $ } = fixture('skip.css'); 67 | console.log($.html()); 68 | const { length } = $('ul').find('a'); 69 | expect(length).to.equal(3); 70 | }); 71 | 72 | it('should generate content', function () { 73 | const { html: $ } = fixture('content.css'); 74 | console.log($.html()); 75 | const content = $('h1').text(); 76 | expect(content).to.equal('Hello world!'); 77 | }); 78 | 79 | it('should generate elements that have no CSS', function () { 80 | const { html: $ } = fixture('empty.css'); 81 | console.log($.html()); 82 | const li = $('ul').find('li'); 83 | expect(li.length).to.equal(3); 84 | const hrefs = li.map(function() { 85 | return $(this).children().first().attr('href') 86 | }).get(); 87 | expect(hrefs.join('')).to.equal('###'); 88 | }); 89 | }); 90 | 91 | describe('css', function () { 92 | it('should maintain nesting by default (legacy: false)', function () { 93 | const source = read('nested.css'); 94 | const { css } = htmlByCss(source, { legacy: false }); 95 | console.log(css); 96 | expect(css).to.include('& li.item'); 97 | }); 98 | 99 | it('should generate valid CSS from single selector', function () { 100 | const { css } = fixture('basic.css'); 101 | console.log(String(css)); 102 | expect(css.rules.total).to.equal(1); 103 | expect(css.selectors.total).to.equal(1); 104 | expect(css.declarations.total).to.equal(3); 105 | }); 106 | 107 | it('should generate valid CSS from nested selectors', function () { 108 | const { css } = fixture('nested.css'); 109 | console.log(String(css)); 110 | expect(css.rules.total).to.equal(2); 111 | expect(css.selectors.total).to.equal(2); 112 | expect(css.declarations.total).to.equal(4); 113 | }); 114 | 115 | it('should not generate emmet selectors', function () { 116 | const { css } = fixture('emmet.css'); 117 | console.log(String(css)); 118 | expect(css.rules.total).to.equal(3); 119 | expect(css.selectors.total).to.equal(3); 120 | expect(css.declarations.total).to.equal(8); 121 | }); 122 | 123 | it('should include rendering using *0', function () { 124 | const { css } = fixture('skip.css'); 125 | console.log(String(css)); 126 | expect(css.rules.total).to.equal(3); 127 | expect(css.selectors.total).to.equal(3); 128 | expect(css.declarations.total).to.equal(8); 129 | }); 130 | 131 | it('should not include content with non-psuedo' , function () { 132 | const source = read('content.css'); 133 | const { css } = htmlByCss(source, { legacy: true }); 134 | console.log(css); 135 | expect(css).to.include("content: '';"); 136 | expect(css).to.not.include("content: Hello world!;"); 137 | }); 138 | 139 | it('should remove empty declarations', function () { 140 | const { css } = fixture('empty.css'); 141 | console.log(String(css)); 142 | expect(css.rules.total).to.equal(0); 143 | expect(css.selectors.total).to.equal(0); 144 | expect(css.declarations.total).to.equal(0); 145 | }); 146 | }); 147 | }); --------------------------------------------------------------------------------