@projectwallace/format-css 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
2
+ # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3
+
4
+ name: Tests
5
+
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ pull_request:
10
+ branches: [main]
11
+
12
+ jobs:
13
+ test:
14
+ name: Unit tests
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v3
18
+ - name: Use Node.js 18
19
+ uses: actions/setup-node@v3
20
+ with:
21
+ node-version: 18
22
+ cache: "npm"
23
+ - run: npm install --ignore-scripts --no-audit
24
+ - run: npm test
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Project Wallace
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.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # format-css
2
+
3
+ Lightweight and fast library to format CSS with some very basic [rules](#formatting-rules). Our design goal is to format CSS in such a way that it's easy to inspect. Bundle size and runtime speed are more important than versatility and extensibility.
4
+
5
+ ![Example input-output of this formatter](https://github.com/projectwallace/format-css/assets/1536852/ce160fd3-fa11-4d90-9432-22567ee1d851)
6
+
7
+ ## Installation
8
+
9
+ ```
10
+ npm install @projectwallace/format-css
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```js
16
+ import { format } from "@projectwallace/format-css";
17
+
18
+ let old_css = "/* Your old CSS here */";
19
+ let new_css = format(old_css);
20
+ ```
21
+
22
+ ## Formatting rules
23
+
24
+ 1. Every **AtRule** starts on a new line
25
+ 1. Every **Rule** starts on a new line
26
+ 1. Every **Selector** starts on a new line
27
+ 1. A comma is placed after every **Selector** that’s not the last in the **SelectorList**
28
+ 1. Every **Block** is indented with 1 tab more than the previous indentation level
29
+ 1. Every **Declaration** starts on a new line
30
+ 1. Every **Declaration** ends with a semicolon (;)
31
+ 1. An empty line is placed after a **Block**, unless it’s the last in the surrounding **Block**
32
+ 1. Unknown syntax is rendered as-is
33
+
34
+ ## Acknowledgements
35
+
36
+ - Thanks to [CSSTree](https://github.com/csstree/csstree) for providing the necessary parser and the interfaces for our CSS Types (the **bold** elements in the list above)
37
+ - Thanks to [Prettier](https://prettier.io) and countless others for prior art
package/index.js ADDED
@@ -0,0 +1,214 @@
1
+ import parse from 'css-tree/parser'
2
+
3
+ /**
4
+ * Indent a string
5
+ * @param {number} size
6
+ * @returns A string with {size} tabs
7
+ */
8
+ function indent(size) {
9
+ return '\t'.repeat(size)
10
+ }
11
+
12
+ /**
13
+ * @param {import('css-tree').CssNode} node
14
+ * @param {string} css
15
+ * @returns A portion of the CSS
16
+ */
17
+ function substr(node, css) {
18
+ if (node.loc) {
19
+ return css.substring(node.loc.start.offset, node.loc.end.offset)
20
+ }
21
+ return ''
22
+ }
23
+
24
+ /**
25
+ *
26
+ * @param {import('css-tree').Rule} node
27
+ * @param {number} indent_level
28
+ * @param {string} css
29
+ * @returns {string} A formatted Rule
30
+ */
31
+ function print_rule(node, indent_level, css) {
32
+ let buffer = ''
33
+
34
+ if (node.prelude && node.prelude.type === 'SelectorList') {
35
+ buffer += print_selectorlist(node.prelude, indent_level, css)
36
+ }
37
+
38
+ if (node.block && node.block.type === 'Block') {
39
+ buffer += print_block(node.block, indent_level, css)
40
+ }
41
+
42
+ return buffer
43
+ }
44
+
45
+ /**
46
+ * @param {import('css-tree').SelectorList} node
47
+ * @param {number} indent_level
48
+ * @param {string} css
49
+ * @returns {string} A formatted SelectorList
50
+ */
51
+ function print_selectorlist(node, indent_level, css) {
52
+ let buffer = ''
53
+
54
+ for (let selector of node.children) {
55
+ if (selector !== node.children.first) {
56
+ buffer += '\n'
57
+ }
58
+
59
+ if (selector.type === 'Selector') {
60
+ buffer += print_selector(selector, indent_level, css)
61
+ } else {
62
+ buffer += print_unknown(selector, indent_level, css)
63
+ }
64
+
65
+ if (selector !== node.children.last) {
66
+ buffer += ','
67
+ }
68
+ }
69
+ return buffer
70
+ }
71
+
72
+ /**
73
+ *
74
+ * @param {import('css-tree').Selector} node
75
+ * @param {number} indent_level
76
+ * @param {string} css
77
+ * @returns {string} A formatted Selector
78
+ */
79
+ function print_selector(node, indent_level, css) {
80
+ return indent(indent_level) + substr(node, css)
81
+ }
82
+
83
+ /**
84
+ * @param {import('css-tree').Block} node
85
+ * @param {number} indent_level
86
+ * @param {string} css
87
+ * @returns {string} A formatted Block
88
+ */
89
+ function print_block(node, indent_level, css) {
90
+ let children = node.children
91
+
92
+ if (children.size === 0) {
93
+ return ' {}\n'
94
+ }
95
+
96
+ let buffer = ' {\n'
97
+
98
+ indent_level++
99
+
100
+ for (let child of children) {
101
+ if (child.type === 'Declaration') {
102
+ buffer += print_declaration(child, indent_level, css)
103
+ } else if (child.type === 'Rule') {
104
+ buffer += print_rule(child, indent_level, css)
105
+ } else if (child.type === 'Atrule') {
106
+ buffer += print_atrule(child, indent_level, css)
107
+ } else {
108
+ buffer += print_unknown(child, indent_level, css)
109
+ }
110
+
111
+ if (child !== children.last) {
112
+ if (child.type === 'Declaration') {
113
+ buffer += '\n'
114
+ } else {
115
+ buffer += '\n\n'
116
+ }
117
+ }
118
+ }
119
+
120
+ indent_level--
121
+
122
+ buffer += '\n'
123
+ buffer += indent(indent_level)
124
+ buffer += '}'
125
+
126
+ return buffer
127
+ }
128
+
129
+ /**
130
+ * @param {import('css-tree').Atrule} node
131
+ * @param {number} indent_level
132
+ * @param {string} css
133
+ * @returns {string} A formatted Atrule
134
+ */
135
+ function print_atrule(node, indent_level, css) {
136
+ let buffer = indent(indent_level)
137
+ buffer += '@' + node.name
138
+
139
+ // @font-face has no prelude
140
+ if (node.prelude) {
141
+ buffer += ' ' + substr(node.prelude, css)
142
+ }
143
+
144
+ if (node.block && node.block.type === 'Block') {
145
+ buffer += print_block(node.block, indent_level, css)
146
+ } else {
147
+ // `@import url(style.css);` has no block, neither does `@layer layer1;`
148
+ buffer += ';'
149
+ }
150
+
151
+ return buffer
152
+ }
153
+
154
+ /**
155
+ * @param {import('css-tree').Declation} node
156
+ * @param {number} indent_level
157
+ * @param {string} css
158
+ * @returns {string} A formatted Declaration
159
+ */
160
+ function print_declaration(node, indent_level, css) {
161
+ return indent(indent_level) + node.property + ': ' + substr(node.value, css) + ';'
162
+ }
163
+
164
+ /**
165
+ * @param {import('css-tree').CssNode} node
166
+ * @param {number} indent_level
167
+ * @param {string} css
168
+ * @returns {string} A formatted unknown CSS string
169
+ */
170
+ function print_unknown(node, indent_level, css) {
171
+ return indent(indent_level) + substr(node, css).trim()
172
+ }
173
+
174
+ /**
175
+ * @param {import('css-tree').Stylesheet} node
176
+ * @param {number} indent_level
177
+ * @param {string} css
178
+ * @returns {string} A formatted Stylesheet
179
+ */
180
+ function print(node, indent_level = 0, css) {
181
+ let buffer = ''
182
+ let children = node.children
183
+
184
+ for (let child of children) {
185
+ if (child.type === 'Rule') {
186
+ buffer += print_rule(child, indent_level, css)
187
+ } else if (child.type === 'Atrule') {
188
+ buffer += print_atrule(child, indent_level, css)
189
+ } else {
190
+ buffer += print_unknown(child, indent_level, css)
191
+ }
192
+
193
+ if (child !== children.last) {
194
+ buffer += '\n\n'
195
+ }
196
+ }
197
+
198
+ return buffer
199
+ }
200
+
201
+ /**
202
+ * Take a string of CSS (minified or not) and format it with some simple rules
203
+ * @param {string} css The original CSS
204
+ * @returns {string} The newly formatted CSS
205
+ */
206
+ export function format(css) {
207
+ let ast = parse(css, {
208
+ positions: true,
209
+ parseAtrulePrelude: false,
210
+ parseCustomProperty: false,
211
+ parseValue: false,
212
+ })
213
+ return print(ast, 0, css)
214
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@projectwallace/format-css",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "source": "index.js",
6
+ "exports": {
7
+ "require": "./dist/format.cjs",
8
+ "default": "./dist/format.modern.js"
9
+ },
10
+ "types": "./dist/index.d.ts",
11
+ "main": "./dist/format.cjs",
12
+ "module": "./dist/format.module.js",
13
+ "unpkg": "./dist/format.umd.js",
14
+ "scripts": {
15
+ "build": "microbundle",
16
+ "test": "uvu"
17
+ },
18
+ "devDependencies": {
19
+ "@types/css-tree": "^2.3.1",
20
+ "microbundle": "^0.15.1",
21
+ "uvu": "^0.5.6"
22
+ },
23
+ "dependencies": {
24
+ "css-tree": "^2.3.1"
25
+ }
26
+ }
package/test.js ADDED
@@ -0,0 +1,373 @@
1
+ import { test } from 'uvu'
2
+ import * as assert from 'uvu/assert'
3
+ import { format } from './index.js'
4
+
5
+ test('AtRules and Rules start on a new line', () => {
6
+ let actual = format(`
7
+ selector { property: value; }
8
+ @media (min-width: 1000px) {
9
+ selector { property: value; }
10
+ }
11
+ selector { property: value; }
12
+ @layer test {
13
+ selector { property: value; }
14
+ }
15
+ `)
16
+ let expected = `selector {
17
+ property: value;
18
+ }
19
+
20
+ @media (min-width: 1000px) {
21
+ selector {
22
+ property: value;
23
+ }
24
+ }
25
+
26
+ selector {
27
+ property: value;
28
+ }
29
+
30
+ @layer test {
31
+ selector {
32
+ property: value;
33
+ }
34
+ }`
35
+
36
+ assert.equal(actual, expected)
37
+ })
38
+
39
+ test('Atrule blocks are surrounded by {} with correct spacing and indentation', () => {
40
+ let actual = format(`
41
+ @media (min-width:1000px){selector{property:value}}
42
+
43
+ @media (min-width:1000px)
44
+ {
45
+ selector
46
+ {
47
+ property:value
48
+ }
49
+ }`)
50
+ let expected = `@media (min-width:1000px) {
51
+ selector {
52
+ property: value;
53
+ }
54
+ }
55
+
56
+ @media (min-width:1000px) {
57
+ selector {
58
+ property: value;
59
+ }
60
+ }`
61
+
62
+ assert.equal(actual, expected)
63
+ })
64
+
65
+ test('Does not do AtRule prelude formatting', () => {
66
+ let actual = format(`@media (min-width:1000px){}`)
67
+ let expected = `@media (min-width:1000px) {}
68
+ `
69
+
70
+ assert.equal(actual, expected)
71
+ })
72
+
73
+ test('Selectors are placed on a new line, separated by commas', () => {
74
+ let actual = format(`
75
+ selector1,
76
+ selector1a,
77
+ selector1b,
78
+ selector1aa,
79
+ selector2,
80
+
81
+ selector3 {
82
+ }
83
+ `)
84
+ let expected = `selector1,
85
+ selector1a,
86
+ selector1b,
87
+ selector1aa,
88
+ selector2,
89
+ selector3 {}
90
+ `
91
+
92
+ assert.equal(actual, expected)
93
+ })
94
+
95
+ test('Declarations end with a semicolon (;)', () => {
96
+ let actual = format(`
97
+ @font-face {
98
+ src: url('test');
99
+ font-family: Test;
100
+ }
101
+
102
+ css {
103
+ property1: value2;
104
+ property2: value2;
105
+
106
+ & .nested {
107
+ property1: value2;
108
+ property2: value2
109
+ }
110
+ }
111
+
112
+ @media (min-width: 1000px) {
113
+ @layer test {
114
+ css {
115
+ property1: value1
116
+ }
117
+ }
118
+ }
119
+ `)
120
+ let expected = `@font-face {
121
+ src: url('test');
122
+ font-family: Test;
123
+ }
124
+
125
+ css {
126
+ property1: value2;
127
+ property2: value2;
128
+ & .nested {
129
+ property1: value2;
130
+ property2: value2;
131
+ }
132
+ }
133
+
134
+ @media (min-width: 1000px) {
135
+ @layer test {
136
+ css {
137
+ property1: value1;
138
+ }
139
+ }
140
+ }`
141
+
142
+ assert.equal(actual, expected)
143
+ })
144
+
145
+ test('An empty line is rendered in between Rules', () => {
146
+ let actual = format(`
147
+ rule1 { property: value }
148
+ rule2 { property: value }
149
+ `)
150
+ let expected = `rule1 {
151
+ property: value;
152
+ }
153
+
154
+ rule2 {
155
+ property: value;
156
+ }`
157
+ assert.equal(actual, expected)
158
+ })
159
+
160
+ test('single empty line after a rule, before atrule', () => {
161
+ let actual = format(`
162
+ rule1 { property: value }
163
+ @media (min-width: 1000px) {
164
+ rule2 { property: value }
165
+ }
166
+ `)
167
+ let expected = `rule1 {
168
+ property: value;
169
+ }
170
+
171
+ @media (min-width: 1000px) {
172
+ rule2 {
173
+ property: value;
174
+ }
175
+ }`
176
+ assert.equal(actual, expected)
177
+ })
178
+
179
+ test('single empty line in between atrules', () => {
180
+ let actual = format(`
181
+ @layer test1;
182
+ @media (min-width: 1000px) {
183
+ rule2 { property: value }
184
+ }
185
+ `)
186
+ let expected = `@layer test1;
187
+
188
+ @media (min-width: 1000px) {
189
+ rule2 {
190
+ property: value;
191
+ }
192
+ }`
193
+ assert.equal(actual, expected)
194
+ })
195
+
196
+ test('handles comments', () => {
197
+ let actual = format(`
198
+ .async-hide {
199
+ opacity: 0;
200
+ }
201
+
202
+ /*!
203
+ * Library vx.x.x (http://css-lib.com)
204
+ * Copyright 1970-1800 CSS Inc.
205
+ * Licensed under MIT (https://example.com)
206
+ */
207
+
208
+ /*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */
209
+
210
+ html /* comment */ {
211
+ font-family /* comment */ : /* comment */ sans-serif;
212
+ -webkit-text-size-adjust: 100%;
213
+ -ms-text-size-adjust: 100%;
214
+ }
215
+ `)
216
+ let expected = `.async-hide {
217
+ opacity: 0;
218
+ }
219
+
220
+ /*!
221
+ * Library vx.x.x (http://css-lib.com)
222
+ * Copyright 1970-1800 CSS Inc.
223
+ * Licensed under MIT (https://example.com)
224
+ */
225
+
226
+ /*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */
227
+
228
+ html {
229
+ font-family: sans-serif;
230
+ -webkit-text-size-adjust: 100%;
231
+ -ms-text-size-adjust: 100%;
232
+ }`
233
+
234
+ assert.equal(actual, expected)
235
+ })
236
+
237
+ test('css nesting chaos', () => {
238
+ let actual = format(`
239
+ /**
240
+ * Comment!
241
+ */
242
+ no-layer-1, no-layer-2 { color: red; font-size: 1rem; COLOR: green; }
243
+ @layer components, deep;
244
+ @layer base { layer-base { color: green; } }
245
+ @layer { @layer named { anon-named { test: 1 } }}
246
+ @media (min-width: 1000px) {
247
+ @layer desktop { layer-desktop { color: blue; } }
248
+ @layer { layer-anon, no-2 { anonymous: 1; } }
249
+ @layer test {}
250
+ @supports (min-width: 1px) {
251
+ @layer deep { layer-deep {} }
252
+ }
253
+ }
254
+ test { a: 1}
255
+ @layer components {
256
+ @layer alert {}
257
+ @layer table {
258
+ @layer tbody, thead;
259
+ layer-components-table { color: yellow; }
260
+ @layer tbody { tbody { border: 1px solid; background: red; } }
261
+ @media (min-width: 30em) {
262
+ @supports (display: grid) {
263
+ @layer thead { thead { border: 1px solid; } }
264
+ }
265
+ }
266
+ }
267
+ }
268
+ @layer components.breadcrumb { layer-components-breadcrumb { } }
269
+
270
+ @font-face {
271
+ font-family: "Test";
272
+ src: url(some-url.woff2);
273
+ }
274
+
275
+ ;;;;;;;;;;;;;;;;;;;
276
+ `)
277
+ let expected = `no-layer-1,
278
+ no-layer-2 {
279
+ color: red;
280
+ font-size: 1rem;
281
+ COLOR: green;
282
+ }
283
+
284
+ @layer components, deep;
285
+
286
+ @layer base {
287
+ layer-base {
288
+ color: green;
289
+ }
290
+ }
291
+
292
+ @layer {
293
+ @layer named {
294
+ anon-named {
295
+ test: 1;
296
+ }
297
+ }
298
+ }
299
+
300
+ @media (min-width: 1000px) {
301
+ @layer desktop {
302
+ layer-desktop {
303
+ color: blue;
304
+ }
305
+ }
306
+
307
+ @layer {
308
+ layer-anon,
309
+ no-2 {
310
+ anonymous: 1;
311
+ }
312
+ }
313
+
314
+ @layer test {}
315
+
316
+
317
+ @supports (min-width: 1px) {
318
+ @layer deep {
319
+ layer-deep {}
320
+
321
+ }
322
+ }
323
+ }
324
+
325
+ test {
326
+ a: 1;
327
+ }
328
+
329
+ @layer components {
330
+ @layer alert {}
331
+
332
+
333
+ @layer table {
334
+ @layer tbody, thead;
335
+
336
+ layer-components-table {
337
+ color: yellow;
338
+ }
339
+
340
+ @layer tbody {
341
+ tbody {
342
+ border: 1px solid;
343
+ background: red;
344
+ }
345
+ }
346
+
347
+ @media (min-width: 30em) {
348
+ @supports (display: grid) {
349
+ @layer thead {
350
+ thead {
351
+ border: 1px solid;
352
+ }
353
+ }
354
+ }
355
+ }
356
+ }
357
+ }
358
+
359
+ @layer components.breadcrumb {
360
+ layer-components-breadcrumb {}
361
+
362
+ }
363
+
364
+ @font-face {
365
+ font-family: "Test";
366
+ src: url(some-url.woff2);
367
+ }
368
+
369
+ ;;;;;;;;;;;;;;;;;;;`
370
+ assert.equal(actual, expected);
371
+ });
372
+
373
+ test.run();