0
Fork 0
mirror of https://github.com/penpot/penpot-export.git synced 2025-02-12 18:18:01 -05:00

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.
This commit is contained in:
Roberto Redradix 2023-08-29 16:28:59 +02:00 committed by Roberto RedRadix
parent d0e18709f1
commit ee2531f6c6
4 changed files with 66 additions and 28 deletions

View file

@ -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 <control>.
*/
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(

View file

@ -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 })
}
}
}

View file

@ -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
}

View file

@ -16,6 +16,6 @@ export interface Config {
}
export interface CSSClassDefinition {
className: string
selector: string
cssProps: Record<string, string>
}