feat(css): add classNameSlug option to createCssContext#4834
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4834 +/- ##
==========================================
+ Coverage 92.84% 92.87% +0.02%
==========================================
Files 177 177
Lines 11643 11726 +83
Branches 3469 3488 +19
==========================================
+ Hits 10810 10890 +80
- Misses 832 835 +3
Partials 1 1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
Hi @flow-pie, Thank you for creating the pull request. It looks good! Could you please consider addressing the following points?
For example, I am considering the following additional changes. diff --git i/src/helper/css/common.ts w/src/helper/css/common.ts
index f8259474..6a8b69d5 100644
--- i/src/helper/css/common.ts
+++ w/src/helper/css/common.ts
@@ -40,8 +40,6 @@ export const rawCssString = (value: string): CssEscapedString => {
* https://github.com/cristianbote/goober/blob/master/src/core/to-hash.js
* MIT License, Copyright (c) 2019 Cristian Bote
*/
-export type ClassNameSlug = (hash: string, label: string, css: string) => string
-
const toHash = (str: string): string => {
let i = 0,
out = 11
@@ -92,6 +90,8 @@ type CssVariableAsyncType = Promise<CssVariableBasicType>
type CssVariableArrayType = (CssVariableBasicType | CssVariableAsyncType)[]
export type CssVariableType = CssVariableBasicType | CssVariableAsyncType | CssVariableArrayType
+export type ClassNameSlug = (hash: string, label: string, styleString: string) => string
+
export const buildStyleString = (
strings: TemplateStringsArray,
values: CssVariableType[]
@@ -202,12 +202,15 @@ export const cxCommon = (
export const keyframesCommon = (
strings: TemplateStringsArray,
- ...values: CssVariableType[]
+ values: CssVariableType[],
+ classNameSlug?: ClassNameSlug
): CssClassName => {
const [label, styleString] = buildStyleString(strings, values)
+ const hash = toHash(label + styleString)
+ const name = classNameSlug ? classNameSlug(hash, label, styleString) : hash
return {
[SELECTOR]: '',
- [CLASS_NAME]: `@keyframes ${toHash(label + styleString)}`,
+ [CLASS_NAME]: `@keyframes ${name}`,
[STYLE_STRING]: styleString,
[SELECTORS]: [],
[EXTERNAL_CLASS_NAMES]: [],
@@ -215,7 +218,11 @@ export const keyframesCommon = (
}
type ViewTransitionType = {
- (strings: TemplateStringsArray, values: CssVariableType[]): CssClassName
+ (
+ strings: TemplateStringsArray,
+ values: CssVariableType[],
+ classNameSlug?: ClassNameSlug
+ ): CssClassName
(content: CssClassName): CssClassName
(): CssClassName
}
@@ -223,19 +230,20 @@ type ViewTransitionType = {
let viewTransitionNameIndex = 0
export const viewTransitionCommon: ViewTransitionType = ((
strings: TemplateStringsArray | CssClassName | undefined,
- values: CssVariableType[]
+ values: CssVariableType[],
+ classNameSlug?: ClassNameSlug
): CssClassName => {
if (!strings) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
strings = [`/* h-v-t ${viewTransitionNameIndex++} */`] as any
}
const content = Array.isArray(strings)
- ? cssCommon(strings as TemplateStringsArray, values)
+ ? cssCommon(strings as TemplateStringsArray, values, classNameSlug)
: (strings as CssClassName)
const transitionName = content[CLASS_NAME]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const res = cssCommon(['view-transition-name:', ''] as any, [transitionName])
+ const res = cssCommon(['view-transition-name:', ''] as any, [transitionName], classNameSlug)
content[CLASS_NAME] = PSEUDO_GLOBAL_SELECTOR + content[CLASS_NAME]
content[STYLE_STRING] = content[STYLE_STRING].replace(
diff --git i/src/helper/css/index.ts w/src/helper/css/index.ts
index 6a6296ae..7f3f6da6 100644
--- i/src/helper/css/index.ts
+++ w/src/helper/css/index.ts
@@ -156,14 +156,15 @@ export const createCssContext = ({
return css(Array(args.length).fill('') as any, ...args)
}
- const keyframes = keyframesCommon
+ const keyframes: KeyframesType = (strings, ...values) =>
+ keyframesCommon(strings, values, classNameSlug)
const viewTransition: ViewTransitionType = ((
strings: TemplateStringsArray | Promise<string> | undefined,
...values: CssVariableType[]
) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- return newCssClassNameObject(viewTransitionCommon(strings as any, values))
+ return newCssClassNameObject(viewTransitionCommon(strings as any, values, classNameSlug))
}) as ViewTransitionType
const Style: StyleType = ({ children, nonce } = {}) =>
diff --git i/src/jsx/dom/css.ts w/src/jsx/dom/css.ts
index 4fdaf6ca..c6a67566 100644
--- i/src/jsx/dom/css.ts
+++ w/src/jsx/dom/css.ts
@@ -4,7 +4,7 @@
*/
import type { FC, PropsWithChildren } from '../'
-import type { CssClassName, CssVariableType } from '../../helper/css/common'
+import type { ClassNameSlug, CssClassName, CssVariableType } from '../../helper/css/common'
import {
CLASS_NAME,
DEFAULT_STYLE_ID,
@@ -168,7 +168,13 @@ interface DefaultContextType {
* `createCssContext` is an experimental feature.
* The API might be changed.
*/
-export const createCssContext = ({ id }: { id: Readonly<string> }): DefaultContextType => {
+export const createCssContext = ({
+ id,
+ classNameSlug,
+}: {
+ id: Readonly<string>
+ classNameSlug?: ClassNameSlug
+}): DefaultContextType => {
const [cssObject, Style] = createCssJsxDomObjects({ id })
const newCssClassNameObject = (cssClassName: CssClassName): string => {
@@ -177,7 +183,7 @@ export const createCssContext = ({ id }: { id: Readonly<string> }): DefaultConte
}
const css: CssType = (strings, ...values) => {
- return newCssClassNameObject(cssCommon(strings, values))
+ return newCssClassNameObject(cssCommon(strings, values, classNameSlug))
}
const cx: CxType = (...args) => {
@@ -187,14 +193,15 @@ export const createCssContext = ({ id }: { id: Readonly<string> }): DefaultConte
return css(Array(args.length).fill('') as any, ...args)
}
- const keyframes: KeyframesType = keyframesCommon
+ const keyframes: KeyframesType = (strings, ...values) =>
+ keyframesCommon(strings, values, classNameSlug)
const viewTransition: ViewTransitionType = ((
strings: TemplateStringsArray | string | undefined,
...values: CssVariableType[]
) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- return newCssClassNameObject(viewTransitionCommon(strings as any, values))
+ return newCssClassNameObject(viewTransitionCommon(strings as any, values, classNameSlug))
}) as ViewTransitionType
return { |
Hi @usualoma I really appreciate the detailed feedback . I’ll go through the points raised and follow up shortly. |
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 honojs#4577
017fb5c to
7920b4f
Compare
|
Hi @usualoma, I’ve amended my last commit and addressed all your review feedback:
|
|
Hi @flow-pie, Thanks for the update! I think this content is fine. |
Hey @yusukebe Based on the thread signals, I think the best approach is to add minimal safe sanitization for
Pushing the update shortly. |
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.
|
@flow-pie First, as a general rule, I don't want to compromise performance in cases where Also, in this context (and generally in many cases), I don't think Furthermore, to ensure users do not have to use It also seems that CSS and keyframe values will require slightly stricter checks. Here is what I am considering:
diff --git c/src/helper/css/common.ts w/src/helper/css/common.ts
index 3029a276..576876c2 100644
--- c/src/helper/css/common.ts
+++ w/src/helper/css/common.ts
@@ -49,14 +49,25 @@ const toHash = (str: string): string => {
return 'css-' + out
}
-const sanitizeClassName = (name: string, fallback: string): string => {
- const sanitized = name
- .trim()
- .replace(/\s+/g, '-')
- .replace(/[^a-zA-Z0-9_-]/g, '')
- return sanitized || fallback
+const normalizeLabel = (label: string): string => {
+ return label.trim().replace(/\s+/g, '-')
}
+const validateClassName = (name: string): string | undefined =>
+ !name || !/^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(name) ? undefined : name
+
+const RESERVED_KEYFRAME_NAMES = new Set([
+ 'default',
+ 'inherit',
+ 'initial',
+ 'none',
+ 'revert',
+ 'revert-layer',
+ 'unset',
+])
+const validateKeyframeName = (name: string): string | undefined =>
+ !validateClassName(name) || RESERVED_KEYFRAME_NAMES.has(name.toLowerCase()) ? undefined : name
+
const cssStringReStr: string = [
'"(?:(?:\\\\[\\s\\S]|[^"\\\\])*)"', // double quoted string
@@ -102,11 +113,11 @@ export type CssVariableType = CssVariableBasicType | CssVariableAsyncType | CssV
* A function that customizes generated CSS class names.
*
* @param hash - The default hash-based class name (e.g. `css-1234567890`)
- * @param label - The comment label extracted from the CSS template, may be empty
+ * @param label - The comment label extracted from the CSS template, may be empty.
+ * Whitespace is trimmed and inner spaces are replaced with hyphens.
* @param styleString - The minified CSS style string
- * @returns The custom class name to use. The returned value will be normalized
- * (whitespace trimmed and collapsed to hyphens) and sanitized (only
- * `[a-zA-Z0-9_-]` characters are kept) internally.
+ * @returns The custom class name to use. Must be a safe CSS identifier;
+ * otherwise, the default hash is used as a fallback.
*/
export type ClassNameSlug = (hash: string, label: string, styleString: string) => string
@@ -185,7 +196,9 @@ export const cssCommon = (
const hash = toHash(label + thisStyleString)
const selector =
(isPseudoGlobal ? PSEUDO_GLOBAL_SELECTOR : '') +
- sanitizeClassName(classNameSlug ? classNameSlug(hash, label, thisStyleString) : hash, hash)
+ ((classNameSlug &&
+ validateClassName(classNameSlug(hash, normalizeLabel(label), thisStyleString))) ||
+ hash)
const className = (
isPseudoGlobal ? selectors.map((s) => s[CLASS_NAME]) : [selector, ...externalClassNames]
).join(' ')
@@ -225,10 +238,11 @@ export const keyframesCommon = (
): CssClassName => {
const [label, styleString] = buildStyleString(strings, values)
const hash = toHash(label + styleString)
- const name = sanitizeClassName(
- classNameSlug ? classNameSlug(hash, label, styleString) : hash,
+ const name =
+ (classNameSlug &&
+ validateKeyframeName(classNameSlug(hash, normalizeLabel(label), styleString))) ||
hash
- )
+
return {
[SELECTOR]: '',
[CLASS_NAME]: `@keyframes ${name}`,
diff --git c/src/helper/css/index.test.tsx w/src/helper/css/index.test.tsx
index cac34437..a5087fdf 100644
--- c/src/helper/css/index.test.tsx
+++ w/src/helper/css/index.test.tsx
@@ -229,10 +229,10 @@ describe('CSS Helper', () => {
it('Should pass label to classNameSlug', async () => {
const { css: customCss, Style: customStyle } = createCssContext({
id: 'label-slug',
- classNameSlug: (hash, label) => (label ? `h${label.trim()}` : hash),
+ classNameSlug: (hash, label) => (label ? `h-${label}` : hash),
})
const headerClass = customCss`
- /* hero-section */
+ /* hero section */
background-color: blue;
`
const template = (
@@ -242,10 +242,39 @@ describe('CSS Helper', () => {
</>
)
const result = await toString(template)
- expect(result).toContain('.hhero-section')
+ expect(result).toContain('.h-hero-section')
expect(result).toContain('background-color:blue')
})
+ it('Should fall back to different hash values for labels with special characters', async () => {
+ const { css: customCss, Style: customStyle } = createCssContext({
+ id: 'label-deferent-slug',
+ classNameSlug: (hash, label) => (label ? `h-${label}` : hash),
+ })
+ const headerClass1 = customCss`
+ /* hero section! */
+ background-color: blue;
+ `
+ const headerClass2 = customCss`
+ /* hero section? */
+ background-color: blue;
+ `
+ const template = (
+ <>
+ {customStyle()}
+ <div class={headerClass1}>Hero</div>
+ <div class={headerClass2}>Hero</div>
+ </>
+ )
+ const result = await toString(template)
+
+ const classes = new Set<string>()
+ result.match(/class="(.*?)"/g)?.forEach((match) => {
+ classes.add(match)
+ })
+ expect(classes.size).toBe(2)
+ })
+
it('Should fall back to hash when label is empty', async () => {
const { css: customCss, Style: customStyle } = createCssContext({
id: 'fallback-slug',
@@ -287,7 +316,7 @@ describe('CSS Helper', () => {
Style: customStyle,
} = createCssContext({
id: 'kf-slug',
- classNameSlug: (hash, label) => (label.trim() ? `h-${label.trim()}` : hash),
+ classNameSlug: (hash, label) => (label ? `h-${label}` : hash),
})
const animation = customKeyframes`
/* fade-in */
@@ -315,7 +344,7 @@ describe('CSS Helper', () => {
Style: customStyle,
} = createCssContext({
id: 'vt-slug',
- classNameSlug: (hash, label) => (label.trim() ? `h-${label.trim()}` : hash),
+ classNameSlug: (hash, label) => (label ? `h-${label}` : hash),
})
const transition = customViewTransition(customCss`
/* hero */
@@ -331,30 +360,10 @@ describe('CSS Helper', () => {
expect(result).toContain('view-transition-name:h-hero')
})
- it('Should sanitize classNameSlug output to safe characters only', async () => {
- const { css: customCss, Style: customStyle } = createCssContext({
- id: 'sanitize-slug',
- classNameSlug: () => ' bad name{color:red} ',
- })
- const headerClass = customCss`
- display: flex;
- `
- const template = (
- <>
- {customStyle()}
- <h1 class={headerClass}>Hello!</h1>
- </>
- )
- const result = await toString(template)
- expect(result).toContain('.bad-namecolorred{display:flex}')
- expect(result).toContain('class="bad-namecolorred"')
- expect(result).not.toContain('bad name{color:red}')
- })
-
- it('Should fall back to hash when classNameSlug returns only invalid characters', async () => {
+ it('Should fall back to hash when classNameSlug returns invalid characters', async () => {
const { css: customCss, Style: customStyle } = createCssContext({
id: 'empty-slug',
- classNameSlug: () => '!!!:::',
+ classNameSlug: () => 'name!',
})
const headerClass = customCss`
display: flex;
@@ -370,10 +379,30 @@ describe('CSS Helper', () => {
expect(result).toContain('display:flex')
})
+ it('Should fall back to hash when classNameSlug starts with a number', async () => {
+ const { css: customCss, Style: customStyle } = createCssContext({
+ id: 'numeric-slug',
+ classNameSlug: () => '1hero',
+ })
+ const headerClass = customCss`
+ display: flex;
+ `
+ const template = (
+ <>
+ {customStyle()}
+ <h1 class={headerClass}>Hello!</h1>
+ </>
+ )
+ const result = await toString(template)
+ expect(result).toContain('.css-')
+ expect(result).toContain('display:flex')
+ expect(result).not.toContain('.1hero')
+ })
+
it('Should fall back to hash when label is empty and classNameSlug returns label', async () => {
const { css: customCss, Style: customStyle } = createCssContext({
id: 'empty-label-slug',
- classNameSlug: (_, label) => label.trim(),
+ classNameSlug: (_, label) => label,
})
const headerClass = customCss`
display: flex;
@@ -388,6 +417,35 @@ describe('CSS Helper', () => {
expect(result).toContain('.css-')
expect(result).toContain('display:flex')
})
+
+ it('Should fall back to hash when keyframes name is a reserved keyword', async () => {
+ const {
+ css: customCss,
+ keyframes: customKeyframes,
+ Style: customStyle,
+ } = createCssContext({
+ id: 'reserved-kf-slug',
+ classNameSlug: () => 'none',
+ })
+ const animation = customKeyframes`
+ from { opacity: 0; }
+ to { opacity: 1; }
+ `
+ const headerClass = customCss`
+ animation: ${animation} 1s ease-in-out;
+ `
+ const template = (
+ <>
+ {customStyle()}
+ <h1 class={headerClass}>Hello!</h1>
+ </>
+ )
+ const result = await toString(template)
+ expect(result).toContain('@keyframes css-')
+ expect(result).toContain('animation:css-')
+ expect(result).not.toContain('@keyframes none')
+ expect(result).not.toContain('animation:none 1s ease-in-out')
+ })
})
describe('with application', () => { |
|
Thanks for the clarification — avoiding sanitization and preserving user intent, as well as keeping the default path free from extra overhead makes sense. There's one small concern I have around developer experience when validation fails and silently falls back to the hash. In cases like:
it may not be immediately obvious why the custom name isn’t applied. To address this without impacting production performance, I’m thinking it would be wise adding a lightweight dev-only warning when an invalid slug is returned (e.g. gated behind "process.env.NODE_ENV !== 'production'"). This would help catch issues early while keeping runtime cost at zero in production. Would you be open to that approach? |
regex |
|
@flow-pie I think your point is valid. However, since the current code in Hono references Since this is essentially the user’s responsibility, I don’t think we need to display anything, but one option might be to use What do you think, @yusukebe? |
Thanks for pointing that out. I know it’s possible to use multibyte characters in class names, but I just can’t imagine any users deliberately trying to use them in this context. |
|
I don't prefer to use an environment variable. In the createCssContext({
id: 'custom-slug',
classNameSlug: (hash, _label, _css) => `btn-${hash.split('-')[1]}`,
onInvalidSlug: () => {
console.error('Invalid slug') // The default is console.warn
},
}) |
|
Hi @yusukebe, Thanks for the comment! |
|
@yusukebe BE CAREFUL. |
Hey, my bad — I didn’t realize it was a personal artwork. I just thought it looked cool and used it without thinking too much about it. I’ve changed my avatar. Sorry about that. |
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
|
Hi @flow-pie, I truly appreciate your contributions to this PR. However, I have to be direct: using another person's avatar without permission is a serious violation of trust, and I believe this is the kind of act that warrants considering a ban from this project. I do believe you have engaged with the discussion here in good faith, which makes this situation all the more disappointing. I want you to understand how significant and problematic this act was. We will discuss how to handle the avatar issue with @yusukebe, but in the meantime, I will leave some comments on the code itself. |
|
I think it would be better to do it this way:
diff --git c/src/helper/css/common.ts w/src/helper/css/common.ts
index dcf6442d..7ea12390 100644
--- c/src/helper/css/common.ts
+++ w/src/helper/css/common.ts
@@ -53,8 +53,7 @@ const normalizeLabel = (label: string): string => {
return label.trim().replace(/\s+/g, '-')
}
-const validateClassName = (name: string): string | undefined =>
- !name || !/^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(name) ? undefined : name
+const isValidClassName = (name: string): boolean => /^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(name)
// CSS-wide keywords that are invalid as @keyframes names per the spec
const RESERVED_KEYFRAME_NAMES = new Set([
@@ -66,8 +65,8 @@ const RESERVED_KEYFRAME_NAMES = new Set([
'revert-layer',
'unset',
])
-const validateKeyframeName = (name: string): string | undefined =>
- !validateClassName(name) || RESERVED_KEYFRAME_NAMES.has(name.toLowerCase()) ? undefined : name
+const isValidKeyframeName = (name: string): boolean =>
+ isValidClassName(name) && !RESERVED_KEYFRAME_NAMES.has(name.toLowerCase())
const cssStringReStr: string = [
'"(?:(?:\\\\[\\s\\S]|[^"\\\\])*)"', // double quoted string
@@ -213,9 +212,12 @@ export const cssCommon = (
let customSlug: string | undefined
if (classNameSlug) {
const slug = classNameSlug(hash, normalizeLabel(label), thisStyleString)
- customSlug = validateClassName(slug)
- if (slug && !customSlug) {
- ;(onInvalidSlug || defaultOnInvalidSlug)(slug)
+ if (slug) {
+ if (isValidClassName(slug)) {
+ customSlug = slug
+ } else {
+ ;(onInvalidSlug || defaultOnInvalidSlug)(slug)
+ }
}
}
const selector = (isPseudoGlobal ? PSEUDO_GLOBAL_SELECTOR : '') + (customSlug || hash)
@@ -259,19 +261,21 @@ export const keyframesCommon = (
): CssClassName => {
const [label, styleString] = buildStyleString(strings, values)
const hash = toHash(label + styleString)
- let name: string | undefined
+ let customSlug: string | undefined
if (classNameSlug) {
const slug = classNameSlug(hash, normalizeLabel(label), styleString)
- name = validateKeyframeName(slug)
- if (slug && !name) {
- ;(onInvalidSlug || defaultOnInvalidSlug)(slug)
+ if (slug) {
+ if (isValidKeyframeName(slug)) {
+ customSlug = slug
+ } else {
+ ;(onInvalidSlug || defaultOnInvalidSlug)(slug)
+ }
}
}
- name ||= hash
return {
[SELECTOR]: '',
- [CLASS_NAME]: `@keyframes ${name}`,
+ [CLASS_NAME]: `@keyframes ${customSlug || hash}`,
[STYLE_STRING]: styleString,
[SELECTORS]: [],
[EXTERNAL_CLASS_NAMES]: [], |
Thanks, . I’ll adjust the implementation to align with your feedback . |
I get your point and will be more mindful of community boundaries moving forward. |
|
@flow-pie Thank you for understanding. If the code is ready for reviewing, please ping us! |
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.
|
Looks good to me! @usualoma Can you approve? |
|
@flow-pie Thank you! I think we can merge it now, too! |
|
@usualoma Thanks! I'll merge this PR immediately. This change is a new feature, but slight, so I will release a patch version with this feature. Thank you! |
Thanks for your review and guidance! |
Description
Adds an optional
classNameSlugfunction tocreateCssContextthat lets users customize generated CSS class names instead of the defaultcss-1234567890format.Closes #4577
Before: Always generates
css-1234567890After: Pass
classNameSlug: (hash, label, css) => stringto get custom namesChanges
src/helper/css/common.ts— exportedClassNameSlugtype, added optionalclassNameSlugparam tocssCommon()src/helper/css/index.ts— addedclassNameSlugoption tocreateCssContext(), exportedClassNameSlugtypesrc/helper/css/index.test.tsx— 4 new tests for custom slug, label extraction, fallback, and default behaviorVerification
API
The function receives:
hash— the defaultcss-1234567890stringlabel— extracted from/* comment */at start of template (may be empty)css— the minified CSS string