diff --git a/package-lock.json b/package-lock.json index 454c570..413bad3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vue3-snapshot-serializer", - "version": "2.9.0", + "version": "2.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vue3-snapshot-serializer", - "version": "2.9.0", + "version": "2.10.0", "license": "MIT", "dependencies": { "cheerio": "^1.0.0", diff --git a/package.json b/package.json index 8aceac1..d427670 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vue3-snapshot-serializer", "type": "module", - "version": "2.9.0", + "version": "2.10.0", "description": "Vitest snapshot serializer for Vue 3 components", "main": "index.js", "scripts": { diff --git a/src/formatters/diffable.js b/src/formatters/diffable.js index fa00cd2..48742c4 100644 --- a/src/formatters/diffable.js +++ b/src/formatters/diffable.js @@ -19,6 +19,7 @@ import { import { debugLogger, escapeHtml, + parseInlineStyles, parseMarkup, unescapeHtml } from '../helpers.js'; @@ -209,6 +210,27 @@ export const diffableFormatter = function (markup) { } } } + + if (attribute.name === 'style') { + const styles = parseInlineStyles(attributeValue); + const stylesOnNewLine = styles.length > options.inlineStylesPerLine; + if (stylesOnNewLine) { + const multiLineStyles = styles + .map((inlineStyle) => { + if (isNewLine) { + return '\n' + ' '.repeat(indent + 2) + inlineStyle; + } + return '\n' + ' '.repeat(indent + 1) + inlineStyle; + }) + .join(''); + if (isNewLine) { + attributeValue = multiLineStyles + '\n' + ' '.repeat(indent + 1); + } else { + attributeValue = multiLineStyles + '\n' + ' '.repeat(indent); + } + } + } + fullAttribute = attribute.name + '="' + attributeValue + '"'; } else { fullAttribute = attribute.name; diff --git a/src/helpers.js b/src/helpers.js index 4febe50..e6edac7 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -249,6 +249,66 @@ export const parseMarkup = function (markup) { return ast; }; +/** + * Takes in the value from an HTML style attribute. + * Splits on the semi-colon, handling edge cases. + * + * @example + * const input = ' color:#F00; padding: 2px '; + * const output = parseInlineStyles(input); + * expect(output) + * .toEqual(['color:#F00;', 'padding: 2px;']); + * + * @param {string} styles Any string of inline styles + * @return {string[]} Array of separated inline styles + */ +export const parseInlineStyles = function (styles) { + debugLogger({ function: 'helpers.js:parseInlineStyles' }); + if (!styles) { + return []; + } + + const pairs = []; + let current = ''; + let insideSingleQuote = false; + let insideDoubleQuote = false; + let parenthesisCount = 0; + + for (let i = 0; i < styles.length; i++) { + const character = styles[i]; + const previousCharacter = styles[i - 1]; + const isEscaped = previousCharacter === '\\'; + + if (character === '\'' && !insideDoubleQuote && !isEscaped) { + insideSingleQuote = !insideSingleQuote; + } else if (character === '"' && !insideSingleQuote && !isEscaped) { + insideDoubleQuote = !insideDoubleQuote; + } else if (character === '(' && !insideSingleQuote && !insideDoubleQuote) { + parenthesisCount = parenthesisCount + 1; + } else if (character === ')' && !insideSingleQuote && !insideDoubleQuote && parenthesisCount) { + parenthesisCount = parenthesisCount - 1; + } + + if (character === ';' && !insideSingleQuote && !insideDoubleQuote && !parenthesisCount) { + pairs.push(current); + current = ''; + } else { + current = current + character; + } + } + + // Add the last pair + pairs.push(current); + + return pairs + .map((pair) => { + return pair.trim() + ';'; + }) + .filter((pair) => { + return pair && pair !== ';'; + }); +}; + /** * Creates a cheerio ($) object from the html for DOM manipulation. * diff --git a/src/loadOptions.js b/src/loadOptions.js index 32cb83d..94632b3 100644 --- a/src/loadOptions.js +++ b/src/loadOptions.js @@ -301,35 +301,34 @@ export const loadOptions = function () { globalThis.vueSnapshots.formatting.tagsWithWhitespacePreserved = TAGS_WITH_WHITESPACE_PRESERVED_DEFAULTS; } - // Formatting - Attributes Per Line - if ( - typeof(globalThis.vueSnapshots.formatting.attributesPerLine) !== 'number' || - globalThis.vueSnapshots.formatting.attributesPerLine < 0 || - globalThis.vueSnapshots.formatting.attributesPerLine % 1 !== 0 - ) { - if (globalThis.vueSnapshots.formatting.attributesPerLine !== undefined) { - logger([ - 'global.vueSnapshots.formatting.attributesPerLine', - 'must be a whole number.' - ].join(' ')); + /** + * The validation for attributesPerLine, classesPerLine, and inlineStylesPerLine + * are all identical, so this DRYs them up. + * + * @param {string} setting The setting name, without "PerLine" + */ + function perLine (setting) { + if ( + typeof(globalThis.vueSnapshots.formatting[setting + 'PerLine']) !== 'number' || + globalThis.vueSnapshots.formatting[setting + 'PerLine'] < 0 || + globalThis.vueSnapshots.formatting[setting + 'PerLine'] % 1 !== 0 + ) { + if (globalThis.vueSnapshots.formatting[setting + 'PerLine'] !== undefined) { + logger([ + 'global.vueSnapshots.formatting.' + setting + 'PerLine', + 'must be a whole number.' + ].join(' ')); + } + globalThis.vueSnapshots.formatting[setting + 'PerLine'] = 1; } - globalThis.vueSnapshots.formatting.attributesPerLine = 1; } + // Formatting - Attributes Per Line + perLine('attributes'); // Formatting - Classes Per Line - if ( - typeof(globalThis.vueSnapshots.formatting.classesPerLine) !== 'number' || - globalThis.vueSnapshots.formatting.classesPerLine < 0 || - globalThis.vueSnapshots.formatting.classesPerLine % 1 !== 0 - ) { - if (globalThis.vueSnapshots.formatting.classesPerLine !== undefined) { - logger([ - 'global.vueSnapshots.formatting.classesPerLine', - 'must be a whole number.' - ].join(' ')); - } - globalThis.vueSnapshots.formatting.classesPerLine = 1; - } + perLine('classes'); + // Formatting - Inline Styles Per Line + perLine('inlineStyles'); // Formatting - Void Elements if (!ALLOWED_VOID_ELEMENTS.includes(globalThis.vueSnapshots.formatting.voidElements)) { @@ -398,6 +397,7 @@ export const loadOptions = function () { ...Object.keys(formattingBooleanDefaults), 'attributesPerLine', 'classesPerLine', + 'inlineStylesPerLine', 'tagsWithWhitespacePreserved', 'voidElements', 'whiteSpacePreservedOption' diff --git a/tests/unit/src/cheerioManipulation.test.js b/tests/unit/src/cheerioManipulation.test.js index df9bd9c..9dc2bab 100644 --- a/tests/unit/src/cheerioManipulation.test.js +++ b/tests/unit/src/cheerioManipulation.test.js @@ -418,7 +418,10 @@ describe('Cheerio Manipulation', () => { .toMatchInlineSnapshot(`

Text @@ -436,7 +439,10 @@ describe('Cheerio Manipulation', () => { .toMatchInlineSnapshot(`

Text diff --git a/tests/unit/src/formatters/diffable.test.js b/tests/unit/src/formatters/diffable.test.js index db4eea4..629986b 100644 --- a/tests/unit/src/formatters/diffable.test.js +++ b/tests/unit/src/formatters/diffable.test.js @@ -839,7 +839,117 @@ describe('diffableFormatter', () => { }); }); - describe('Classes and attributes per line', () => { + describe('Inline Styles Per Line', () => { + beforeEach(() => { + MyComponent = { + template: ` + + + ` + }; + }); + + test('Inline Styles Per Line set to 0', async () => { + const wrapper = mount(MyComponent); + globalThis.vueSnapshots.formatting.inlineStylesPerLine = 0; + + expect(wrapper) + .toMatchInlineSnapshot(` + + + + + `); + }); + + test('Inline Styles Per Line set to Default', async () => { + const wrapper = mount(MyComponent); + globalThis.vueSnapshots.formatting.inlineStylesPerLine = 1; + + expect(wrapper) + .toMatchInlineSnapshot(` + + + + + `); + }); + + test('Inline Styles Per Line set to 2', async () => { + const wrapper = mount(MyComponent); + globalThis.vueSnapshots.formatting.inlineStylesPerLine = 2; + + expect(wrapper) + .toMatchInlineSnapshot(` + + + + + `); + }); + + test('Inline Styles Per Line set to 3', async () => { + const wrapper = mount(MyComponent); + globalThis.vueSnapshots.formatting.inlineStylesPerLine = 3; + + expect(wrapper) + .toMatchInlineSnapshot(` + + + + + `); + }); + + test('Inline Styles Per Line set to 0 with no inline styles', async () => { + MyComponent = { + template: `

+ Empty attribute example +

` + }; + const wrapper = mount(MyComponent); + globalThis.vueSnapshots.formatting.emptyAttributes = true; + globalThis.vueSnapshots.formatting.inlineStylesPerLine = 0; + + expect(wrapper) + .toMatchInlineSnapshot(` +

+ Empty attribute example +

+ `); + + globalThis.vueSnapshots.formatting.emptyAttributes = false; + + expect(wrapper) + .toMatchInlineSnapshot(` +

+ Empty attribute example +

+ `); + }); + }); + + describe('Classes and Attributes Per Line', () => { beforeEach(() => { MyComponent = { template: '' @@ -898,6 +1008,65 @@ describe('diffableFormatter', () => { }); }); + describe('Inline Styles and Attributes Per Line', () => { + beforeEach(() => { + MyComponent = { + template: '' + }; + }); + + test('Defaults', async () => { + const wrapper = mount(MyComponent); + globalThis.vueSnapshots.formatting.attributesPerLine = 1; + globalThis.vueSnapshots.formatting.inlineStylesPerLine = 1; + + expect(wrapper) + .toMatchInlineSnapshot(` + + `); + }); + + test('Weird, but accurate', async () => { + const wrapper = mount(MyComponent); + globalThis.vueSnapshots.formatting.attributesPerLine = 2; + globalThis.vueSnapshots.formatting.inlineStylesPerLine = 1; + + expect(wrapper) + .toMatchInlineSnapshot(` + + `); + }); + + test('Double zero', async () => { + MyComponent = { + template: '' + }; + const wrapper = mount(MyComponent); + globalThis.vueSnapshots.formatting.attributesPerLine = 0; + globalThis.vueSnapshots.formatting.inlineStylesPerLine = 0; + + expect(wrapper) + .toMatchInlineSnapshot(` + + `); + }); + }); + describe('Tags with whitespace preserved', () => { beforeEach(() => { MyComponent = { diff --git a/tests/unit/src/helpers.test.js b/tests/unit/src/helpers.test.js index 2572594..5c23cda 100644 --- a/tests/unit/src/helpers.test.js +++ b/tests/unit/src/helpers.test.js @@ -5,6 +5,7 @@ import { isHtmlString, isVueWrapper, logger, + parseInlineStyles, parseMarkup, stringify, swapQuotes @@ -199,6 +200,29 @@ describe('Helpers', () => { }); }); + describe('ParseInlineStyles', () => { + test('Debug mode', () => { + globalThis.vueSnapshots.debug = true; + parseInlineStyles(''); + + expect(console.info) + .toHaveBeenCalledWith('V3SS Debug:', { function: 'helpers.js:parseInlineStyles' }); + }); + + test('Edgecases', () => { + const input = [ + 'color:#F00;', + 'content: \';\';', + 'background-image: url(data:image/svg+xml;base64,...);', + 'text-decoration: none;', + 'content: ";";' + ]; + + expect(parseInlineStyles(input.join(''))) + .toEqual(input); + }); + }); + describe('Cheerioize', () => { test('Debug mode', () => { globalThis.vueSnapshots.debug = true; diff --git a/tests/unit/src/loadOptions.test.js b/tests/unit/src/loadOptions.test.js index 6bf892f..af310b3 100644 --- a/tests/unit/src/loadOptions.test.js +++ b/tests/unit/src/loadOptions.test.js @@ -22,6 +22,7 @@ describe('Load options', () => { ...formattingBooleanDefaults, attributesPerLine: 1, classesPerLine: 1, + inlineStylesPerLine: 1, tagsWithWhitespacePreserved: ['a', 'pre'], voidElements: 'xhtml' } @@ -79,6 +80,7 @@ describe('Load options', () => { emptyAttributes: true, escapeAttributes: false, escapeInnerText: true, + inlineStylesPerLine: 1, selfClosingTag: false, tagsWithWhitespacePreserved: ['a', 'pre'], voidElements: 'xhtml' @@ -121,6 +123,7 @@ describe('Load options', () => { emptyAttributes: true, escapeAttributes: false, escapeInnerText: true, + inlineStylesPerLine: 1, selfClosingTag: false, tagsWithWhitespacePreserved: ['a', 'pre'], voidElements: 'xhtml' @@ -601,6 +604,47 @@ describe('Load options', () => { }); }); + + describe('Diffable Formatter inlineStylesPerLine Options', () => { + beforeEach(() => { + globalThis.vueSnapshots.formatter = 'diffable'; + globalThis.vueSnapshots.formatting = {}; + }); + + const testCases = [ + [-1, 1], + [0, 0], + ['', 1], + [true, 1], + [100, 100], + [7.5, 1], + [null, 1] + ]; + + test.each(testCases)('Inline styles per line when value is "%s"', (value, expected) => { + globalThis.vueSnapshots.formatting.inlineStylesPerLine = value; + loadOptions(); + + expect(global.vueSnapshots.formatting.inlineStylesPerLine) + .toEqual(expected); + }); + + test('Logger message', () => { + globalThis.vueSnapshots.formatting.inlineStylesPerLine = 3.5; + loadOptions(); + + expect(globalThis.vueSnapshots.formatting.inlineStylesPerLine) + .toEqual(1); + + expect(console.info) + .toHaveBeenCalledWith([ + 'Vue 3 Snapshot Serializer:', + 'global.vueSnapshots.formatting.inlineStylesPerLine', + 'must be a whole number.' + ].join(' ')); + }); + }); + describe('Classic formatter', () => { test('Logs that classic formatting is ignored', () => { globalThis.vueSnapshots.formatter = 'none'; diff --git a/types.js b/types.js index 23d0cb0..18fd9a0 100644 --- a/types.js +++ b/types.js @@ -49,6 +49,7 @@ * @property {boolean} [emptyAttributes=true] Determines whether empty attributes will include `=""`. If false then ``` becomes ``. * @property {boolean} [escapeAttributes=false] Retains (if `true`) or discards (if `false`) named HTML entity encodings, like `<` instead of `<` in HTML attributes. * @property {boolean} [escapeInnerText=true] Retains (if `true`) or discards (if `false`) named HTML entity encodings, like `<` instead of `<` in HTML text nodes. + * @property {number} [inlineStylesPerLine=1] How many inline styles are allowed on the same line as the style attribute. * @property {boolean} [selfClosingTag=false] Converts `
` to `
` or `

` to `

`. Does not affect void elements (like ``), use the `voidElements` setting for them. * @property {string[]} [tagsWithWhitespacePreserved=['a','pre']] Does not add returns and indentation to the inner content of these tags when formatting. Accepts an array of tags name strings. * @property {VOIDELEMENTS} [voidElements='xhtml'] Determines how void elements are closed. Accepts 'html' for ``, 'xhtml' for ``, and 'xml' for ``.