Skip to content

Commit f82aba8

Browse files
authored
feat(css): add classNameSlug option to createCssContext (#4834)
* feat(css): add classNameSlug option to createCssContext Add an optional classNameSlug function to createCssContext that lets users customize generated CSS class names instead of the default hash-based 'css-1234567890' format. The function receives (hash, label, styleString) and returns the desired class name string. This enables readable, debug-friendly class names like 'h-hero-section' while preserving hash-based uniqueness. Addresses review feedback: - Move ClassNameSlug type near CssVariableType exports - Rename third parameter from css to styleString - Propagate classNameSlug to keyframesCommon and viewTransitionCommon - Add classNameSlug support to DOM-side createCssContext - Add TSDoc documentation Closes #4577 * fix(css): sanitize and normalize classNameSlug output Normalize and sanitize the return value of classNameSlug to prevent CSS injection and improve readability: - Trim leading/trailing whitespace - Collapse whitespace sequences to hyphens (e.g. 'ultra fast' -> 'ultra-fast') - Strip characters outside [a-zA-Z0-9_-] - Fall back to hash if result is empty Addresses review feedback from yusukebe and usualoma regarding potential CSS injection through malicious classNameSlug functions. * feat(css): add onInvalidSlug and unify server/DOM contexts Add strict CSS identifier validation for classNameSlug output and onInvalidSlug callback for invalid slugs, unified across server and DOM-side createCssContext. - Replace sanitizeClassName with validateClassName/validateKeyframeName (rejects digit-leading names and reserved @Keyframes keywords) - Add onInvalidSlug option (defaults to console.warn) - Normalize labels (trim + hyphenate) before passing to classNameSlug * feat(css): refine classNameSlug with strict validation and onInvalidSlug Refine classNameSlug implementation by replacing sanitization with strict validation and adding an onInvalidSlug callback. - Add isValidClassName and isValidKeyframeName for strict identifier checks (^?-?[_a-zA-Z][_a-zA-Z0-9-]*$). - Reject CSS-wide reserved keywords for @Keyframes animation names. - Pre-normalize labels (trim + hyphenate) before passing to classNameSlug. - Add optional onInvalidSlug callback to createCssContext for custom error handling (defaults to console.warn). - Ensure parity between SSR and DOM-side createCssContext. - Ensure the default path remains zero-overhead when classNameSlug is unused. * test: remove .trim() usage in classNameSlug test
1 parent 9f374a5 commit f82aba8

5 files changed

Lines changed: 435 additions & 21 deletions

File tree

src/helper/css/common.ts

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,29 @@ const toHash = (str: string): string => {
4949
return 'css-' + out
5050
}
5151

52+
const normalizeLabel = (label: string): string => {
53+
return label.trim().replace(/\s+/g, '-')
54+
}
55+
56+
const isValidClassName = (name: string): boolean => /^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(name)
57+
58+
// CSS-wide keywords that are invalid as @keyframes names per the spec
59+
const RESERVED_KEYFRAME_NAMES = new Set([
60+
'default',
61+
'inherit',
62+
'initial',
63+
'none',
64+
'revert',
65+
'revert-layer',
66+
'unset',
67+
])
68+
const isValidKeyframeName = (name: string): boolean =>
69+
isValidClassName(name) && !RESERVED_KEYFRAME_NAMES.has(name.toLowerCase())
70+
71+
const defaultOnInvalidSlug = (slug: string) => {
72+
console.warn(`Invalid slug: ${slug}`)
73+
}
74+
5275
const cssStringReStr: string = [
5376
'"(?:(?:\\\\[\\s\\S]|[^"\\\\])*)"', // double quoted string
5477

@@ -90,6 +113,25 @@ type CssVariableAsyncType = Promise<CssVariableBasicType>
90113
type CssVariableArrayType = (CssVariableBasicType | CssVariableAsyncType)[]
91114
export type CssVariableType = CssVariableBasicType | CssVariableAsyncType | CssVariableArrayType
92115

116+
/**
117+
* A function that customizes generated CSS class names.
118+
*
119+
* @param hash - The default hash-based class name (e.g. `css-1234567890`)
120+
* @param label - The comment label extracted from the CSS template, may be empty.
121+
* Whitespace is trimmed and inner spaces are replaced with hyphens.
122+
* @param styleString - The minified CSS style string
123+
* @returns The custom class name to use. Must be a safe CSS identifier;
124+
* otherwise, the default hash is used as a fallback.
125+
*/
126+
export type ClassNameSlug = (hash: string, label: string, styleString: string) => string
127+
128+
/**
129+
* A callback function called when an invalid slug is returned from ClassNameSlug.
130+
*
131+
* @param slug - The invalid slug
132+
*/
133+
export type OnInvalidSlug = (slug: string) => void
134+
93135
export const buildStyleString = (
94136
strings: TemplateStringsArray,
95137
values: CssVariableType[]
@@ -154,14 +196,30 @@ export const buildStyleString = (
154196

155197
export const cssCommon = (
156198
strings: TemplateStringsArray,
157-
values: CssVariableType[]
199+
values: CssVariableType[],
200+
classNameSlug?: ClassNameSlug,
201+
onInvalidSlug?: OnInvalidSlug
158202
): CssClassName => {
159203
let [label, thisStyleString, selectors, externalClassNames] = buildStyleString(strings, values)
160204
const isPseudoGlobal = isPseudoGlobalSelectorRe.exec(thisStyleString)
161205
if (isPseudoGlobal) {
162206
thisStyleString = isPseudoGlobal[1]
163207
}
164-
const selector = (isPseudoGlobal ? PSEUDO_GLOBAL_SELECTOR : '') + toHash(label + thisStyleString)
208+
const hash = toHash(label + thisStyleString)
209+
210+
let customSlug: string | undefined
211+
if (classNameSlug) {
212+
const slug = classNameSlug(hash, normalizeLabel(label), thisStyleString)
213+
if (slug) {
214+
if (isValidClassName(slug)) {
215+
customSlug = slug
216+
} else {
217+
;(onInvalidSlug || defaultOnInvalidSlug)(slug)
218+
}
219+
}
220+
}
221+
222+
const selector = (isPseudoGlobal ? PSEUDO_GLOBAL_SELECTOR : '') + (customSlug || hash)
165223
const className = (
166224
isPseudoGlobal ? selectors.map((s) => s[CLASS_NAME]) : [selector, ...externalClassNames]
167225
).join(' ')
@@ -196,40 +254,67 @@ export const cxCommon = (
196254

197255
export const keyframesCommon = (
198256
strings: TemplateStringsArray,
199-
...values: CssVariableType[]
257+
values: CssVariableType[],
258+
classNameSlug?: ClassNameSlug,
259+
onInvalidSlug?: OnInvalidSlug
200260
): CssClassName => {
201261
const [label, styleString] = buildStyleString(strings, values)
262+
const hash = toHash(label + styleString)
263+
264+
let customSlug: string | undefined
265+
if (classNameSlug) {
266+
const slug = classNameSlug(hash, normalizeLabel(label), styleString)
267+
if (slug) {
268+
if (isValidKeyframeName(slug)) {
269+
customSlug = slug
270+
} else {
271+
;(onInvalidSlug || defaultOnInvalidSlug)(slug)
272+
}
273+
}
274+
}
275+
202276
return {
203277
[SELECTOR]: '',
204-
[CLASS_NAME]: `@keyframes ${toHash(label + styleString)}`,
278+
[CLASS_NAME]: `@keyframes ${customSlug || hash}`,
205279
[STYLE_STRING]: styleString,
206280
[SELECTORS]: [],
207281
[EXTERNAL_CLASS_NAMES]: [],
208282
}
209283
}
210284

211285
type ViewTransitionType = {
212-
(strings: TemplateStringsArray, values: CssVariableType[]): CssClassName
286+
(
287+
strings: TemplateStringsArray,
288+
values: CssVariableType[],
289+
classNameSlug?: ClassNameSlug,
290+
onInvalidSlug?: OnInvalidSlug
291+
): CssClassName
213292
(content: CssClassName): CssClassName
214293
(): CssClassName
215294
}
216295

217296
let viewTransitionNameIndex = 0
218297
export const viewTransitionCommon: ViewTransitionType = ((
219298
strings: TemplateStringsArray | CssClassName | undefined,
220-
values: CssVariableType[]
299+
values: CssVariableType[],
300+
classNameSlug?: ClassNameSlug,
301+
onInvalidSlug?: OnInvalidSlug
221302
): CssClassName => {
222303
if (!strings) {
223304
// eslint-disable-next-line @typescript-eslint/no-explicit-any
224305
strings = [`/* h-v-t ${viewTransitionNameIndex++} */`] as any
225306
}
226307
const content = Array.isArray(strings)
227-
? cssCommon(strings as TemplateStringsArray, values)
308+
? cssCommon(strings as TemplateStringsArray, values, classNameSlug, onInvalidSlug)
228309
: (strings as CssClassName)
229310

230311
const transitionName = content[CLASS_NAME]
231-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
232-
const res = cssCommon(['view-transition-name:', ''] as any, [transitionName])
312+
const res = cssCommon(
313+
['view-transition-name:', ''] as any, // eslint-disable-line @typescript-eslint/no-explicit-any
314+
[transitionName],
315+
classNameSlug,
316+
onInvalidSlug
317+
)
233318

234319
content[CLASS_NAME] = PSEUDO_GLOBAL_SELECTOR + content[CLASS_NAME]
235320
content[STYLE_STRING] = content[STYLE_STRING].replace(

0 commit comments

Comments
 (0)