Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add configurable void element endings #9

Merged
merged 10 commits into from
Nov 2, 2024
9 changes: 9 additions & 0 deletions jsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@@/*": ["tests/*"]
}
}
}
105 changes: 69 additions & 36 deletions src/formatMarkup.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,44 @@
// @ts-check
/** @import { DefaultTreeAdapterMap } from "parse5" */

import { parseFragment } from 'parse5';

import { logger } from './helpers.js';

/**
* @typedef {object} OPTIONS
* @property {boolean} [showEmptyAttributes=true] Determines whether empty attributes will include `=""`. <div class> or <div class="">
* @typedef {object} OPTIONS
* @property {boolean} [showEmptyAttributes=true] Determines whether empty attributes will include `=""`. <div class> or <div class="">
* @property {'html'|'xhtml'|'closingTag'} [options.voidElements='xhtml'] How to handle void elements. html = <img>, xhtml = <img />, closingTag = <img></img>
*/

/**
* @type {OPTIONS}
*/
export let DIFFABLE_OPTIONS_TYPE;

// From https://developer.mozilla.org/en-US/docs/Glossary/Void_element
const VOID_ELEMENTS = Object.freeze([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr'
]);

const WHITESPACE_DEPENDENT_TAGS = Object.freeze([
'a',
'pre'
]);

/**
* Uses Parse5 to create an AST from the markup. Loops over the AST to create a formatted HTML string.
*
Expand All @@ -25,40 +52,30 @@ export const diffableFormatter = function (markup, options) {
if (typeof(options.showEmptyAttributes) !== 'boolean') {
options.showEmptyAttributes = true;
}
if (!['html', 'xhtml', 'closingTag'].includes(options.voidElements)) {
options.voidElements = 'xhtml';
}

const astOptions = {
sourceCodeLocationInfo: true
};
const ast = parseFragment(markup, astOptions);

// From https://developer.mozilla.org/en-US/docs/Glossary/Void_element
const VOID_ELEMENTS = Object.freeze([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr'
]);
const WHITESPACE_DEPENDENT_TAGS = Object.freeze([
'a',
'pre'
]);

let lastSeenTag = '';

/**
* Applies formatting to each DOM Node in the AST.
*
* @param {DefaultTreeAdapterMap["childNode"]} node Parse5 AST of a DOM node
* @param {number} indent The current indentation level for this DOM node in the AST loop
* @return {string} Formatted markup
*/
const formatNode = (node, indent) => {
indent = indent || 0;
if (node.tagName) {
lastSeenTag = node.tagName;
}

const tagIsWhitespaceDependent = WHITESPACE_DEPENDENT_TAGS.includes(lastSeenTag);
const tagIsVoidElement = VOID_ELEMENTS.includes(lastSeenTag);
const hasChildren = node.childNodes && node.childNodes.length;
Expand Down Expand Up @@ -109,16 +126,25 @@ export const diffableFormatter = function (markup, options) {
return '\n' + ' '.repeat(indent) + '<!--' + data + '-->';
}

// <tags and="attributes" />
let result = '\n' + ' '.repeat(indent) + '<' + node.nodeName;

let endingAngleBracket = '>';
if (tagIsVoidElement) {
if (
tagIsVoidElement &&
options.voidElements === 'xhtml'
) {
endingAngleBracket = ' />';
}

// Add attributes
if (node?.attrs?.length === 1) {
let attr = node?.attrs[0];
if (
!node.tagName ||
!node.attrs.length
) {
result = result + endingAngleBracket;
} else if (node.attrs?.length === 1) {
let attr = node.attrs[0];
if (
!attr.value &&
!options.showEmptyAttributes
Expand All @@ -127,7 +153,7 @@ export const diffableFormatter = function (markup, options) {
} else {
result = result + ' ' + attr.name + '="' + attr.value + '"' + endingAngleBracket;
}
} else if (node?.attrs?.length) {
} else if (node.attrs?.length) {
node.attrs.forEach((attr) => {
if (
!attr.value &&
Expand All @@ -139,8 +165,6 @@ export const diffableFormatter = function (markup, options) {
}
});
result = result + '\n' + ' '.repeat(indent) + endingAngleBracket.trim();
} else {
result = result + endingAngleBracket;
}

// Process child nodes
Expand All @@ -150,12 +174,21 @@ export const diffableFormatter = function (markup, options) {
});
}

if (!tagIsVoidElement) {
if (tagIsWhitespaceDependent || !hasChildren) {
result = result + '</' + node.nodeName + '>';
} else {
result = result + '\n' + ' '.repeat(indent) + '</' + node.nodeName + '>';
}
// Add closing tag
if (
tagIsWhitespaceDependent ||
(
!tagIsVoidElement &&
!hasChildren
) ||
(
tagIsVoidElement &&
options.voidElements === 'closingTag'
)
) {
result = result + '</' + node.nodeName + '>';
} else if (!tagIsVoidElement) {
result = result + '\n' + ' '.repeat(indent) + '</' + node.nodeName + '>';
}

return result;
Expand Down
51 changes: 51 additions & 0 deletions tests/unit/src/formatMarkup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,38 @@ const formattedMarkup = `
</div>
`.trim();

const unformattedMarkupVoidElements = `
<input>
<input type="range"><input type="range" max="50">
`.trim();

const formattedMarkupVoidElementsWithHTML = `
<input>
<input type="range">
<input
type="range"
max="50"
>
`.trim();

const formattedMarkupVoidElementsWithXHTML = `
<input />
<input type="range" />
<input
type="range"
max="50"
/>
`.trim();

const formattedMarkupVoidElementsWithClosingTag = `
<input></input>
<input type="range"></input>
<input
type="range"
max="50"
></input>
`.trim();

describe('Format markup', () => {
const info = console.info;

Expand Down Expand Up @@ -216,4 +248,23 @@ describe('Format markup', () => {
`);
});
});

describe('Void elements', () => {
beforeEach(() => {
globalThis.vueSnapshots.formatter = 'diffable';
});

const voidElementTests = [
['html', formattedMarkupVoidElementsWithHTML],
['xhtml', formattedMarkupVoidElementsWithXHTML],
['closingTag', formattedMarkupVoidElementsWithClosingTag]
];

test.each(voidElementTests)('Formats void elements using mode "%s"', (mode, expected) => {
globalThis.vueSnapshots.formatting.voidElements = mode;

expect(formatMarkup(unformattedMarkupVoidElements))
.toEqual(expected);
});
});
});