From ee2531f6c6ac2d357a4ed80b8626c50179146f7c Mon Sep 17 00:00:00 2001 From: Roberto Redradix Date: Tue, 29 Aug 2023 16:28:59 +0200 Subject: [PATCH] chore(cli)!: avoid non-obvious CSS class names clashing errors As observed in fa873130, current classname PoC tranformation is prone to name clashing errors because it replaces everything not matching `/[a-zA-Z_-|0-9]/g` with underscores. However, as names from typography assets coming from Penpot can hold these values (and often they will, because designers consider emoji useful specially in asset names), the tranformation needed a more thorough algorhythm to avoid clashing errors. This implementation follows the CSS specification and browser behaviours, which allow more than those characters in class names (including non-ASCII Unicode characters, and even escaped ASCII characters that CSS uses in its syntax), to output valid CSS class nameswith a minimum surface of clashing possibilities. Since any transformation will decrease the entropy because CSS syntax is a subset of Unicode, there are inevitable clash zones, though. Those zones are reduced to two easily observable cases: duplicated names with explicit underscores instead of spaces, and duplicated names with surrounding whitespace; e.g. "My typography", "My_typography", " My typography" or "My typography " will clash if any pair of those ever happen together. BREAKING CHANGE: class names generation is no longer case insensitive. --- packages/penpot-css-export/src/lib/css.ts | 63 +++++++++++++++++++- packages/penpot-css-export/src/lib/index.ts | 17 ++---- packages/penpot-css-export/src/lib/string.ts | 12 ---- packages/penpot-css-export/src/lib/types.ts | 2 +- 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/packages/penpot-css-export/src/lib/css.ts b/packages/penpot-css-export/src/lib/css.ts index b7d8b1d..bfad605 100644 --- a/packages/penpot-css-export/src/lib/css.ts +++ b/packages/penpot-css-export/src/lib/css.ts @@ -2,6 +2,65 @@ import fs from 'fs' import { camelToKebab } from './string' import { CSSClassDefinition } from './types' +/** From: https://www.w3.org/TR/css-syntax-3/#escaping + * Any Unicode code point can be included in an ident sequence or quoted string by escaping it. CSS escape sequences + * start with a backslash (\), and continue with: + * - Any Unicode code point that is not a hex digits or a newline. The escape sequence is replaced by that code point. + * - Or one to six hex digits, followed by an optional whitespace. The escape sequence is replaced by the Unicode code + * point whose value is given by the hexadecimal digits. This optional whitespace allow hexadecimal escape sequences + * to be followed by "real" hex digits. + */ +function escapeCssCharacter(char: string) { + return '\\' + char +} + +/** From: https://www.w3.org/TR/css-syntax-3/#syntax-description: + * Property names and at-rule names are always ident sequences, which have to start with an ident-start code point, + * two hyphens, or a hyphen followed by an ident-start code point, and then can contain zero or more ident code points. + * You can include any code point at all, even ones that CSS uses in its syntax, by escaping it. + * + * Railroad diagram: https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + * + * ident-start code point: A letter, a non-ASCII code point, or U+005F LOW LINE (_). + * ident code point: An ident-start code point, a digit, or U+002D HYPHEN-MINUS (-). + * digit: A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. + * letter: An uppercase letter or a lowercase letter. + * non-ASCII code point: A code point with a value equal to or greater than U+0080 . + */ +function textToCssIdentToken(str: string) { + const normalizedString = str.trim().replace(/\s/g, '_') + + const escapedString = normalizedString + .replace( + // NOTE All ASCII printable characters except -, 0-9, A-Z, _, a-z + /[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, + escapeCssCharacter, + ) + .replace( + /^(-?)([0-9])/, + (match: string, dashGroup: string, numberGroup: string) => + dashGroup + escapeCssCharacter(numberGroup), + ) + + return escapedString +} + +/** From: https://www.w3.org/TR/selectors-3/#w3cselgrammar + * class + * : '.' IDENT + * ; + * + * Tokenizer (Flex notation) + * ident [-]?{nmstart}{nmchar}* + * nmstart [_a-z]|{nonascii}|{escape} + * nmchar [_a-z0-9-]|{nonascii}|{escape} + * nonascii [^\0-\177] + */ +export function textToCssClassSelector(str: string) { + const ident = textToCssIdentToken(str) + return '.' + ident +} + export function cssClassDefinitionToCSS( cssClassDefinition: CSSClassDefinition, ): string { @@ -9,9 +68,7 @@ export function cssClassDefinitionToCSS( (key) => ` ${camelToKebab(key)}: ${cssClassDefinition.cssProps[key]};`, ) - return [`.${cssClassDefinition.className} {`, ...cssValidProps, '}'].join( - '\n', - ) + return [`${cssClassDefinition.selector} {`, ...cssValidProps, '}'].join('\n') } export function writeCssFile( diff --git a/packages/penpot-css-export/src/lib/index.ts b/packages/penpot-css-export/src/lib/index.ts index cb982b5..922201c 100644 --- a/packages/penpot-css-export/src/lib/index.ts +++ b/packages/penpot-css-export/src/lib/index.ts @@ -1,8 +1,7 @@ import Penpot from '../lib/api' -import { textToValidClassname } from './string' import { validateAndNormalizePenpotExportConfig } from './config' import { CSSClassDefinition, Config } from './types' -import { writeCssFile } from './css' +import { textToCssClassSelector, writeCssFile } from './css' import path from 'path' export async function generateCssFromConfig( @@ -18,15 +17,11 @@ export async function generateCssFromConfig( const { fileName, typographies } = await penpot.getFileTypographies({ fileId: typographiesConfig.fileId, }) - const fileClassname = textToValidClassname(fileName) for (const typography of typographies) { - if (typography.path === '📱Title') continue // FIXME Conflicting emoji names - const cssProps = Penpot.getTypographyAssetCssProps(typography) - const objectClassname = textToValidClassname(typography.name) - const className = `${fileClassname}--${objectClassname}` - cssClassDefinitions.push({ className, cssProps }) + const selector = textToCssClassSelector(`${fileName}--${typography.name}`) + cssClassDefinitions.push({ selector, cssProps }) } const cssPath = path.resolve(rootProjectPath, typographiesConfig.output) @@ -43,15 +38,13 @@ export async function generateCssFromConfig( fileId: page.fileId, pageId: page.pageId, }) - const pageClassname = textToValidClassname(pageName) for (const component of components) { for (const object of component.objects) { if (object.type === 'text') { const cssProps = Penpot.getTextObjectCssProps(object) - const objectClassname = textToValidClassname(object.name) - const className = `${pageClassname}--${objectClassname}` - cssClassDefinitions.push({ className, cssProps }) + const selector = textToCssClassSelector(`${pageName}--${object.name}`) + cssClassDefinitions.push({ selector, cssProps }) } } } diff --git a/packages/penpot-css-export/src/lib/string.ts b/packages/penpot-css-export/src/lib/string.ts index 820f0fd..1186463 100644 --- a/packages/penpot-css-export/src/lib/string.ts +++ b/packages/penpot-css-export/src/lib/string.ts @@ -1,15 +1,3 @@ export function camelToKebab(str: string) { return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase() } - -export function textToValidClassname(str: string) { - // Remove invalid characters - let className = str.toLowerCase().replace(/[^a-zA-Z_-|0-9]/g, '_') - - // If it starts with a hyphen, ensure it's not followed by a digit - if (str.startsWith('-')) { - className = str.replace(/^-([0-9])/, '_$1') - } - - return className -} diff --git a/packages/penpot-css-export/src/lib/types.ts b/packages/penpot-css-export/src/lib/types.ts index 48175ba..60137b8 100644 --- a/packages/penpot-css-export/src/lib/types.ts +++ b/packages/penpot-css-export/src/lib/types.ts @@ -16,6 +16,6 @@ export interface Config { } export interface CSSClassDefinition { - className: string + selector: string cssProps: Record }