import { parse as htmlParse } from 'node-html-parser';
import { parse } from 'postcss';
export type SelectorType = 'class' | 'id' | 'hash';
export interface PluginOptions {
html: string;
selector?: SelectorType | SelectorType[];
styleTags?: boolean;
indent?: number;
}
interface Style {
selector: string,
props: string[]
}
/**
* Formats a class selector.
* @param selector - The selector to format.
* @returns The formatted class selector.
*/
const formatClass = (selector: string) => {
return selector.trim()
.replace(/^([^.])/, '.$1')
.replace(/ +/g, ' ')
.replace(/ /g, '.');
};
/**
* Formats an ID selector.
* @param selector - The selector to format.
* @returns The formatted ID selector.
*/
const formatId = (selector: string) => {
return selector.trim()
.replace(/^([^#])/, '#$1');
};
/**
* Formats a selector by adding spaces around combinators and commas.
* @param selector - The selector to format.
* @returns The formatted selector.
*/
const formatSelector = (selector: string) => {
return selector.trim()
.replace(/>/g, ' > ')
.replace(/ +,/g, ', ')
.replace(/ +/g, ' ');
};
/**
* Formats style properties into an array.
* @param props - The style properties to format.
* @returns An array of formatted style properties.
*/
const formatProps = (props: string | undefined) => {
return props?.split(';').reduce((list: string[], prop) => {
if (prop.trim()) {
list.push(prop.split(':').map(kv => kv.trim()).join(': '));
}
return list;
}, []).sort() || [];
};
/**
* Generates a selector based on class, ID, or a random hash.
* @param className - The class name to use in the selector.
* @param id - The ID to use in the selector.
* @param selector - The type of selector to generate.
* @returns The generated selector.
*/
const generateSelector = (className: string, id: string, selector: SelectorType | SelectorType[]) => {
const template = /^\d/;
const generateHash = () => {
let hash = '';
do {
hash = Math.random().toString(36).slice(2);
} while (template.test(hash));
return hash;
};
return (Array.isArray(selector) ? selector : [selector]).reduce((name, type) => {
return name || type === 'class' ?
formatClass(className || '') : type === 'id' ?
formatId(id || '') : `.${generateHash()}`;
}, '');
};
/**
* Compares two selectors to determine if they are equivalent.
* @param selector1 - The first selector to compare.
* @param selector2 - The second selector to compare.
* @returns True if the selectors are equivalent, false otherwise.
*/
const compareSelector = (selector1: string, selector2: string) => {
return /[\s>+~(),]/.test(selector1) || /[\s>+~(),]/.test(selector2)
? selector1 === selector2
: selector1.split('.').sort().join('.') === selector2.split('.').sort().join('.');
};
/**
* Merges two sets of properties, ensuring no duplicates and sorting the result.
* @param props1 - The first set of properties.
* @param props2 - The second set of properties.
* @returns A sorted array of merged properties.
*/
const mergeProps = (props1: string[], props2: string[]) => {
const props = props1.slice();
for (const prop of props2) {
if (props.indexOf(prop) < 0) {
props.push(prop);
}
}
return props.sort();
};
/**
* Extracts styles from the provided HTML content based on the specified options.
* @param options - The options for extracting styles.
* @returns An array of extracted styles, each with a selector and properties.
*/
export const extractStyles = (options: Required) => {
const { html, selector, styleTags } = options;
const context = htmlParse(html);
const result: Style[] = [];
for (const el of context.querySelectorAll('[style]')) {
const value = generateSelector(el.attributes.class || '', el.attributes.id || '', selector);
if (value) {
const index = result.findIndex(style => compareSelector(style.selector, value));
if (index > -1 && result[index]) {
result[index].props = mergeProps(result[index].props, formatProps(el.attributes.style));
} else {
result.push({
selector: value,
props: formatProps(el.attributes.style)
});
}
}
}
if (styleTags) {
for (const el of context.querySelectorAll('style')) {
parse(el.textContent).walkRules(rule => {
const value = formatSelector(rule.selector);
const props = rule.nodes.filter(node => node.type === 'decl').map(decl => `${decl.prop}: ${decl.value}`);
const index = result.findIndex(style => compareSelector(style.selector, value));
if (index > -1 && result[index]) {
result[index].props = mergeProps(result[index].props, props);
} else {
result.push({
selector: value,
props
});
}
});
}
}
return result;
};
/**
* Formats the extracted styles into a string of CSS.
* @param styles - The styles to format.
* @param indent - The number of spaces to use for indentation.
* @returns A formatted string of CSS styles.
*/
export const format = (styles: Style[], indent: number) => {
const line = [];
for (const { selector, props } of styles) {
line.push(`${selector} {`);
for (const prop of props) {
line.push(`${''.padStart(indent, ' ')}${prop};`);
}
line.push('}\n');
}
return line.join('\n');
};