diff --git a/packages/stylify/src/Compiler/Compiler.ts b/packages/stylify/src/Compiler/Compiler.ts index 554f5c35..a18d51d4 100755 --- a/packages/stylify/src/Compiler/Compiler.ts +++ b/packages/stylify/src/Compiler/Compiler.ts @@ -10,6 +10,7 @@ import { } from '.'; import { hooks } from '../Hooks'; +import { escapeCssSelector, getStringOriginalStateAfterReplace, prepareStringForReplace, tokenize } from '../Utilities'; export interface CompilerContentOptionsInterface { pregenerate: PregenerateType, @@ -96,8 +97,11 @@ export interface CompilerConfigInterface { matchCustomSelectors?: boolean } +export type CustomSelectorTypeType = 'custom' | 'customMatchedInClass' | 'component' | 'utilitiesGroup'; + export interface CustomSelectorsInterface { - customSelectors: CustomSelector[] + customSelectors: CustomSelector[], + type: CustomSelectorTypeType } export interface ComponentGeneratorFunctionDataInterface { @@ -119,10 +123,6 @@ export class Compiler { private readonly textPlaceholder = '_TEXT_'; - private readonly dollarPlaceholder = '_DOLLAR_'; - - private readonly backslashPlaceholder = '_BACKSLASH_'; - private readonly variableRegExp = /\$([\w-_]+)/g; private readonly contentOptionsRegExp = /stylify-([a-zA-Z-_0-9]+)\s([\s\S]+?)\s\/stylify-[a-zA-Z-_0-9]+/; @@ -254,7 +254,12 @@ export class Compiler { } } - public addCustomSelector(selector: string, selectors: string, selectorCanBeSplit = true): void { + public addCustomSelector( + selector: string, + selectors: string, + selectorCanBeSplit = true, + type: CustomSelectorTypeType = 'custom' + ): void { if (selectorCanBeSplit && selector.includes(',')) { selector.split(',').forEach((selectorSplit) => this.addCustomSelector(selectorSplit.trim(), selectors)); return; @@ -262,7 +267,8 @@ export class Compiler { if (!(selector in this.customSelectors)) { this.customSelectors[selector] = { - customSelectors: [] + customSelectors: [], + type }; } @@ -330,8 +336,6 @@ export class Compiler { return content; } - const dollarPlaceholderRegExp = new RegExp(this.dollarPlaceholder, 'g'); - const backslashPlaceholderRegExp = new RegExp(this.backslashPlaceholder, 'g'); const placeholderTextPart = this.textPlaceholder; const contentPlaceholders: Record = {}; @@ -341,15 +345,7 @@ export class Compiler { return placeholderKey; }; - // This replaces special characters used within regular expression - // so their are not processed during replacing - // $ => because $$ causes $ - // \ => because \u or \v for example are predefined character sets - const replaceSpecialCharacters = (content: string) => content - .replace(/\$/g, this.dollarPlaceholder) - .replace(/\\/g, this.backslashPlaceholder); - - content = replaceSpecialCharacters(content) + content = prepareStringForReplace(content) .replace(new RegExp(this.ignoredAreasRegExpString, 'g'), (...args): string => { const matchArguments = args.filter((value) => typeof value === 'string'); const fullMatch: string = matchArguments[0]; @@ -368,7 +364,7 @@ export class Compiler { .sort((a: string, b: string): number => b.length - a.length); for (const selector of selectorsListKeys) { - let selectorToReplace = replaceSpecialCharacters(selector); + let selectorToReplace = prepareStringForReplace(selector); if (!content.includes(selectorToReplace)) { continue; @@ -380,10 +376,8 @@ export class Compiler { : ''; selectorToReplace = selectorToReplace.replace(/\\/g, '\\\\'); - selectorToReplace = this.escapeCssSelector( - minifiedSelectorGenerator.getStringToMatch( - selectorToReplace, matchSelectorsWithPrefixes - ) + selectorToReplace = escapeCssSelector( + minifiedSelectorGenerator.getStringToMatch(selectorToReplace, matchSelectorsWithPrefixes) ); const replacement = `${selectorPrefix}${mangledSelector}`; @@ -409,9 +403,7 @@ export class Compiler { content = content.replace(placeholderKey, contentPlaceholder); } - content = content - .replace(dollarPlaceholderRegExp, '$$') - .replace(backslashPlaceholderRegExp, '\\'); + content = getStringOriginalStateAfterReplace(content); return content; } @@ -466,9 +458,10 @@ export class Compiler { : fullMatch; this.addCustomSelector( - `.${this.escapeCssSelector(customSelectorSelector, true)}`, + customSelectorSelector, `${customSelector}{${stylifySelectors.replace(/;/g, ' ')}}`, - false + false, + 'customMatchedInClass' ); return ''; @@ -483,11 +476,12 @@ export class Compiler { : fullMatch; this.addCustomSelector( - `.${this.escapeCssSelector(customSelectorSelector, true)}`, + customSelectorSelector, stylifySelectors.split(';') .map((stylifySelector) => `${screenAndPseudoClasses}:${stylifySelector}`) .join(' '), - false + false, + 'utilitiesGroup' ); return ''; @@ -543,28 +537,6 @@ export class Compiler { return contentOptions as Data; } - private escapeCssSelector(selector: string, all = false): string { - const selectorLength = selector.length; - let result = ''; - const regExp = all ? /[^a-zA-Z0-9]/ : /[.*+?^${}()|[\]\\]/; - - for (let i = 0; i < selectorLength; i++) { - const char = selector[i]; - const escapeCharacter = '\\'; - if (['-', '_'].includes(char) - || !regExp.test(char) - || selector[i - 1] === escapeCharacter - ) { - result += char; - continue; - } - - result += `\\${char}`; - } - - return result; - } - private configureCompilationResult(compilationResult: CompilationResult): CompilationResult { const newLine = this.dev ? '\n' : ''; @@ -658,31 +630,66 @@ export class Compiler { const selectorsMap: Record = {}; for (const [selector, config] of Object.entries(this.customSelectors)) { + const isComponent = config.type === 'component'; + const isUtilitiesGroup = config.type === 'utilitiesGroup'; + const isCustomSelectorMatchedInClass = config.type === 'customMatchedInClass'; + const isClassSelector = isComponent || isUtilitiesGroup || isCustomSelectorMatchedInClass; + const preparedEscapedSelector = config.type === 'custom' + ? selector + : escapeCssSelector(selector, isComponent || isUtilitiesGroup || isCustomSelectorMatchedInClass); + for (const customSelector of config.customSelectors) { - const generatedCssEntries = customSelector.generateSelectors(selector); + const generatedCssEntries = customSelector.generateSelectors( + `${isClassSelector ? '.':''}${preparedEscapedSelector}` + ); - for (const [customSelector, customSelectorSelectors] of Object.entries(generatedCssEntries)) { - if (!(customSelector in selectorsMap)) { - selectorsMap[customSelector] = ''; - } + let cssSelector: string; + let stylifySelectors: string; + for ([cssSelector, stylifySelectors] of Object.entries(generatedCssEntries)) { + if (this.mangleSelectors && isComponent) { + const preparedCssSelector = prepareStringForReplace(cssSelector); + const preparedSelector = prepareStringForReplace(preparedEscapedSelector); + + cssSelector = preparedCssSelector.replace( + new RegExp(`\\.(${preparedSelector}\\S*)`), + (fullMatch: string, componentSelector: string) => { + let clearedComponentName = ''; + const tokenizableComponentSelector = getStringOriginalStateAfterReplace( + componentSelector + ); - const selectorsToAdd = selectorsMap[customSelector].length - ? selectorsMap[customSelector].split(' ') - : []; + tokenize(tokenizableComponentSelector, ({ token, previousToken }) => { + const isCorrectToken = (/\w|-|\\|\$/).test(token); - customSelectorSelectors.split(' ').forEach((selector) => { - if (!selectorsToAdd.includes(selector)) { - selectorsToAdd.push(selector); - } - }); + if (!isCorrectToken && previousToken !== '\\') { + return true; + } + + clearedComponentName += token; + }); + + return fullMatch.substring(1).replace( + prepareStringForReplace(clearedComponentName), + `.${minifiedSelectorGenerator.getMangledSelector(clearedComponentName)}` + ); + }); + } - selectorsMap[ - this.rewriteSelectors({ - content: customSelector, + if (this.mangleSelectors) { + cssSelector = this.rewriteSelectors({ + content: cssSelector, rewriteOnlyInSelectorsAreas: false, matchSelectorsWithPrefixes: true + }); + } + + selectorsMap[cssSelector] = [selectorsMap[cssSelector] ?? '', ...stylifySelectors.split(' ')] + .filter((item, index, self) => { + return item.trim().length > 0 && self.indexOf(item) === index; }) - ] = selectorsToAdd.filter((item) => item.trim().length > 0).join(' ').replace(/\s/g, ' ').trim(); + .join(' ') + .replace(/\s/g, ' ') + .trim(); } } } @@ -696,11 +703,7 @@ export class Compiler { let componentMatch: RegExpExecArray; while ((componentMatch = regExp.exec(content))) { - const componentClassSelector = `.${ - this.mangleSelectors - ? minifiedSelectorGenerator.getMangledSelector(componentMatch[0]) - : this.escapeCssSelector(componentMatch[0], true) - }`; + const componentSelector = componentMatch[0]; for (const selectorsOrGenerator of config.selectorsOrGenerators) { const componentSelectors = typeof selectorsOrGenerator === 'function' @@ -712,14 +715,9 @@ export class Compiler { }) : selectorsOrGenerator; - const customSelector = new CustomSelector(componentSelectors); - const generatedCssEntries = Object.entries( - customSelector.generateSelectors(componentClassSelector) - ); + minifiedSelectorGenerator.getMangledSelector(componentSelector); - for (const [componentCustomSelector, selectorsToAttach] of generatedCssEntries) { - this.addCustomSelector(componentCustomSelector, selectorsToAttach); - } + this.addCustomSelector(componentSelector, componentSelectors, false, 'component'); } } } diff --git a/packages/stylify/src/Compiler/CustomSelector.ts b/packages/stylify/src/Compiler/CustomSelector.ts index 121f137c..3c193785 100644 --- a/packages/stylify/src/Compiler/CustomSelector.ts +++ b/packages/stylify/src/Compiler/CustomSelector.ts @@ -54,9 +54,8 @@ export class CustomSelector { } else { actualTreeSelector += ` ${replaceRootPlaceholder( - selectorIncludesPlaceholder - ? selectorSplitPart.substring(1) - : selectorSplitPart, actualTreeSelector + selectorIncludesPlaceholder ? selectorSplitPart.substring(1) : selectorSplitPart, + actualTreeSelector )}`; } diff --git a/packages/stylify/src/Utilities/index.ts b/packages/stylify/src/Utilities/index.ts index f1d2a3a7..3fd75979 100644 --- a/packages/stylify/src/Utilities/index.ts +++ b/packages/stylify/src/Utilities/index.ts @@ -1,2 +1,3 @@ export * from './objects'; export * from './numbers'; +export * from './strings'; diff --git a/packages/stylify/src/Utilities/strings.ts b/packages/stylify/src/Utilities/strings.ts new file mode 100644 index 00000000..7c68d4b3 --- /dev/null +++ b/packages/stylify/src/Utilities/strings.ts @@ -0,0 +1,70 @@ +export const dollarPlaceholder = '_DLR_'; + +export const backslashPlaceholder = '_BSLASH_'; + +export type TokenizerCallbackType = (data: { + iterator: number, + token: string|undefined, + nextToken: string|undefined, + previousToken: string|undefined, + tokensCount: number, + isLastToken: boolean, + isFirstToken: boolean +}) => boolean|void; + +export const escapeCssSelector = (selector: string, all = false) => { + const selectorLength = selector.length; + let result = ''; + const regExp = all ? /[^a-zA-Z0-9]/ : /[.*+?^${}()|[\]\\]/; + + for (let i = 0; i < selectorLength; i++) { + const char = selector[i]; + const escapeCharacter = '\\'; + + if (['-', '_'].includes(char) || !regExp.test(char) || selector[i - 1] === escapeCharacter) { + result += char; + continue; + } + + result += `\\${char}`; + } + + return result; +}; + +export const tokenize = (content: string, tokenizerCallback: TokenizerCallbackType): void => { + const tokens = content.split(''); + const tokensCount = tokens.length; + + for (let i = 0; i < tokensCount; i ++) { + if (tokenizerCallback({ + token: tokens[i], + nextToken: tokens[i + 1], + previousToken: tokens[i - 1], + iterator: i, + isLastToken: i + 1 === tokensCount, + isFirstToken: i === 0, + tokensCount + })) { + break; + } + } +}; + +const dollarPlaceholderRegExp = new RegExp(dollarPlaceholder, 'g'); +const backslashPlaceholderRegExp = new RegExp(backslashPlaceholder, 'g'); + +export const prepareStringForReplace = (content: string) => { + + /* + This replaces special characters used within regular expression + so their are not processed during replacing + $ => because $$ causes $ + \ => because \u or \v for example are predefined character sets + */ + return content.replace(/\$/g, dollarPlaceholder).replace(/\\/g, backslashPlaceholder); +}; + +export const getStringOriginalStateAfterReplace = (content: string) => { + return content.replace(dollarPlaceholderRegExp, '$$').replace(backslashPlaceholderRegExp, '\\'); +}; diff --git a/packages/stylify/tests/jest/components.test.ts b/packages/stylify/tests/jest/components.test.ts index 95f747da..4f98f2ed 100644 --- a/packages/stylify/tests/jest/components.test.ts +++ b/packages/stylify/tests/jest/components.test.ts @@ -112,17 +112,19 @@ test('Components - nested syntax - mangled', (): void => { ...nestedSyntaxCompilerConfig }); + // This order is on purpose. + // This selector uses component .header (below) that was not yet defined. compiler.addCustomSelector('#wrapper', ` .not-minified-2 { color:darkred } .header { color:green } header { color:orange } - `) + `); compiler.addComponent('header', ` font-style:italic + #header { color:purple } - `) + `); const input = testUtils.getHtmlInputFile(testFileName); let compilationResult = compiler.compile(input); diff --git a/packages/stylify/tests/jest/components/expected/mangle-dynamic-components.css b/packages/stylify/tests/jest/components/expected/mangle-dynamic-components.css index a8c4c13e..7e34cb1a 100644 --- a/packages/stylify/tests/jest/components/expected/mangle-dynamic-components.css +++ b/packages/stylify/tests/jest/components/expected/mangle-dynamic-components.css @@ -2,12 +2,12 @@ --red: darkred; } .a, -.b{ +.c{ font-size: 24px } .a{ color: #000 } -.b{ +.c{ color: darkred } diff --git a/playground.mjs b/playground.mjs new file mode 100644 index 00000000..9f29dd3a --- /dev/null +++ b/playground.mjs @@ -0,0 +1,21 @@ +import { Compiler } from './packages/stylify/esm/index.mjs'; + +const compiler = new Compiler({ + dev: true, + mangleSelectors: false, + variables: { + + }, + components: { + + } +}); + +const content = ` + +`.trim(); + +const compilationResult = compiler.compile(content); +console.log('\n\n-------------'); +console.log(compiler.rewriteSelectors(content)); +console.log(compilationResult.generateCss());