diff --git a/src/lib/index.ts b/src/lib/index.ts index 5d3ef74..5fce82d 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -583,3 +583,59 @@ export function format( export function minify(css: string): string { return format(css, { minify: true }) } + +export const LINE_TYPE_SELECTOR = 1 +export const LINE_TYPE_DECLARATION = 2 +export const LINE_TYPE_BRACKET = 3 +export const LINE_TYPE_ATRULE = 4 +export const LINE_TYPE_COMMENT = 5 +export const LINE_TYPE_EMPTY = 6 + +export type LineType = + | typeof LINE_TYPE_SELECTOR + | typeof LINE_TYPE_DECLARATION + | typeof LINE_TYPE_BRACKET + | typeof LINE_TYPE_ATRULE + | typeof LINE_TYPE_COMMENT + | typeof LINE_TYPE_EMPTY + +function classify_lines(lines: string[]): LineType[] { + let types: LineType[] = [] + let in_multiline_comment = false + + for (let line of lines) { + let trimmed = line.trimEnd() + + if (in_multiline_comment) { + types.push(LINE_TYPE_COMMENT) + if (trimmed.includes('*/')) in_multiline_comment = false + continue + } + + if (trimmed === '') { + types.push(LINE_TYPE_EMPTY) + } else if (trimmed.trimStart().startsWith('/*')) { + types.push(LINE_TYPE_COMMENT) + if (!trimmed.slice(trimmed.indexOf('/*') + 2).includes('*/')) in_multiline_comment = true + } else if (trimmed.endsWith(CLOSE_BRACE)) { + types.push(LINE_TYPE_BRACKET) + } else if (trimmed.trimStart().startsWith('@')) { + types.push(LINE_TYPE_ATRULE) + } else if (trimmed.endsWith(OPEN_BRACE) || trimmed.endsWith(COMMA)) { + types.push(LINE_TYPE_SELECTOR) + } else { + types.push(LINE_TYPE_DECLARATION) + } + } + + return types +} + +export function format_with_types( + css: string, + options: Omit = Object.create(null), +): { css: string; types: LineType[] } { + let formatted = format(css, options) + let types = classify_lines(formatted.split('\n')) + return { css: formatted, types } +} diff --git a/test/format-with-types.test.ts b/test/format-with-types.test.ts new file mode 100644 index 0000000..5e2390e --- /dev/null +++ b/test/format-with-types.test.ts @@ -0,0 +1,156 @@ +import { describe, test, expect } from 'vitest' +import { + format_with_types, + LINE_TYPE_SELECTOR, + LINE_TYPE_DECLARATION, + LINE_TYPE_BRACKET, + LINE_TYPE_ATRULE, + LINE_TYPE_COMMENT, + LINE_TYPE_EMPTY, +} from '../src/lib/index.js' + +describe('format_with_types', () => { + test('simple rule', () => { + let { css, types } = format_with_types('a { color: red; }') + expect(css).toBe('a {\n\tcolor: red;\n}') + expect(types).toEqual([LINE_TYPE_SELECTOR, LINE_TYPE_DECLARATION, LINE_TYPE_BRACKET]) + }) + + test('multiple declarations', () => { + let { css, types } = format_with_types('a { color: red; background: blue; }') + expect(types).toEqual([ + LINE_TYPE_SELECTOR, + LINE_TYPE_DECLARATION, + LINE_TYPE_DECLARATION, + LINE_TYPE_BRACKET, + ]) + }) + + test('multi-line selector list', () => { + let { css, types } = format_with_types('a, b { color: red; }') + expect(css).toBe('a,\nb {\n\tcolor: red;\n}') + expect(types).toEqual([ + LINE_TYPE_SELECTOR, + LINE_TYPE_SELECTOR, + LINE_TYPE_DECLARATION, + LINE_TYPE_BRACKET, + ]) + }) + + test('atrule with block', () => { + let { css, types } = format_with_types('@media all { a { color: red } }') + expect(css).toBe('@media all {\n\ta {\n\t\tcolor: red;\n\t}\n}') + expect(types).toEqual([ + LINE_TYPE_ATRULE, + LINE_TYPE_SELECTOR, + LINE_TYPE_DECLARATION, + LINE_TYPE_BRACKET, + LINE_TYPE_BRACKET, + ]) + }) + + test('atrule without block (@charset)', () => { + let { css, types } = format_with_types('@charset "utf-8";') + expect(css).toBe('@charset "utf-8";') + expect(types).toEqual([LINE_TYPE_ATRULE]) + }) + + test('atrule without block (@import)', () => { + let { css, types } = format_with_types('@import "foo.css";') + expect(css).toBe('@import "foo.css";') + expect(types).toEqual([LINE_TYPE_ATRULE]) + }) + + test('empty line between rules', () => { + let { css, types } = format_with_types('a {} b {}') + expect(css).toBe('a {}\n\nb {}') + expect(types).toEqual([LINE_TYPE_BRACKET, LINE_TYPE_EMPTY, LINE_TYPE_BRACKET]) + }) + + test('single-line comment before rule', () => { + let { css, types } = format_with_types('/* comment */ a { color: red; }') + expect(css).toBe('/* comment */\na {\n\tcolor: red;\n}') + expect(types).toEqual([ + LINE_TYPE_COMMENT, + LINE_TYPE_SELECTOR, + LINE_TYPE_DECLARATION, + LINE_TYPE_BRACKET, + ]) + }) + + test('single-line comment inside rule', () => { + let { css, types } = format_with_types('a { /* comment */ color: red; }') + expect(css).toBe('a {\n\t/* comment */\n\tcolor: red;\n}') + expect(types).toEqual([ + LINE_TYPE_SELECTOR, + LINE_TYPE_COMMENT, + LINE_TYPE_DECLARATION, + LINE_TYPE_BRACKET, + ]) + }) + + test('multiline comment before rule', () => { + let { css, types } = format_with_types('/* line 1\n line 2 */ a { color: red; }') + expect(css).toBe('/* line 1\n line 2 */\na {\n\tcolor: red;\n}') + expect(types).toEqual([ + LINE_TYPE_COMMENT, + LINE_TYPE_COMMENT, + LINE_TYPE_SELECTOR, + LINE_TYPE_DECLARATION, + LINE_TYPE_BRACKET, + ]) + }) + + test('multiline comment inside rule', () => { + let { css, types } = format_with_types( + 'a {\n\t/* line 1\n\t line 2 */\n\tcolor: red;\n}', + ) + expect(css).toBe('a {\n\t/* line 1\n\t line 2 */\n\tcolor: red;\n}') + expect(types).toEqual([ + LINE_TYPE_SELECTOR, + LINE_TYPE_COMMENT, + LINE_TYPE_COMMENT, + LINE_TYPE_DECLARATION, + LINE_TYPE_BRACKET, + ]) + }) + + test('multiline comment spanning three lines', () => { + let { css, types } = format_with_types('/* a\n * b\n * c */ a { color: red; }') + expect(css).toBe('/* a\n * b\n * c */\na {\n\tcolor: red;\n}') + expect(types).toEqual([ + LINE_TYPE_COMMENT, + LINE_TYPE_COMMENT, + LINE_TYPE_COMMENT, + LINE_TYPE_SELECTOR, + LINE_TYPE_DECLARATION, + LINE_TYPE_BRACKET, + ]) + }) + + test('comment containing a semicolon is not a declaration', () => { + let { css, types } = format_with_types('a { /* color: red; */ color: blue; }') + expect(types).toEqual([ + LINE_TYPE_SELECTOR, + LINE_TYPE_COMMENT, + LINE_TYPE_DECLARATION, + LINE_TYPE_BRACKET, + ]) + }) + + test('comment containing a brace is not a bracket', () => { + let { css, types } = format_with_types('/* } */ a { color: red; }') + expect(types[0]).toBe(LINE_TYPE_COMMENT) + }) + + test('types array length matches number of lines', () => { + let input = '@media all { a, b { color: red; background: blue; } }' + let { css, types } = format_with_types(input) + expect(types).toHaveLength(css.split('\n').length) + }) + + test('tab_size option is forwarded', () => { + let { css } = format_with_types('a { color: red; }', { tab_size: 2 }) + expect(css).toBe('a {\n color: red;\n}') + }) +})