From 5557b249b7845cd4b295da3459b9082b12e62ce4 Mon Sep 17 00:00:00 2001
From: Alberto Masuelli
Date: Fri, 31 Jul 2026 19:17:46 -0300
Subject: [PATCH 1/7] feat: support at-rule statements and classes inside
at-rule blocks
---
CHANGELOG.md | 29 +++++++++
README.md | 94 +++++++++++++++++++++++++++--
docs/AGENTS.md | 45 +++++++++++++-
docs/api.md | 28 +++++++--
src/StyleSheet.js | 51 ++++++++++++----
src/css.js | 22 ++++++-
test-d/index.test-d.ts | 34 +++++++++++
test/index.test.js | 130 +++++++++++++++++++++++++++++++++++++++++
types/StyleSheet.d.ts | 48 ++++++++++-----
types/css.d.ts | 3 +-
10 files changed, 445 insertions(+), 39 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd03dc4..766fa38 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- At-rule statements: an at-rule key holding a plain value is rendered as `@rule prelude;`, with the prelude emitted as it is written.
+
+ ```javascript
+ css({
+ '@import' : 'url("reset.css") layer(base)', // → @import url("reset.css") layer(base);
+ '@layer' : 'base, components' // → @layer base, components;
+ });
+ ```
+
+ Object keys are unique, so a stylesheet holds one statement per at-rule name.
+
+- Class names are now generated inside at-rule blocks as well as at the top level, so a class can be declared directly inside a `@layer` (or `@media`, …) instead of being declared empty at the top level. The same name used in several places shares a single class.
+
+ ```javascript
+ const { classes } = css({
+ '@layer base' : { button : { color : 'black' } },
+ '@layer theme' : { button : { color : 'blue' } }
+ });
+
+ classes.button; // the same class in both layers
+ ```
+
+### Fixed
+
+- Fixed `&` with no parent selector rendering the literal string `undefined` (e.g. `undefined:hover`). The missing parent is now treated as an empty string (`&:hover` → `:hover`).
+- Fixed keys holding a plain value generating a class name that matched no rule.
+
## [0.1.0] - 2026-07-25
### Added
diff --git a/README.md b/README.md
index 2bde143..0b666eb 100644
--- a/README.md
+++ b/README.md
@@ -76,7 +76,7 @@ const { css } = CSSFUN;
### Create your styles
-`css()` injects the styles and returns a `StyleSheet` instance. Its `classes` property holds the generated class names, one per top-level selector:
+`css()` injects the styles and returns a `StyleSheet` instance. Its `classes` property maps class name selectors to their generated unique class name:
```javascript
const { classes } = css({
@@ -99,7 +99,7 @@ See [Class Name Generation](#class-name-generation) for how the names are built.
## Class Name Generation
-`css(styles)` returns a [`StyleSheet`](/docs/api.md#stylesheet) instance. Its `classes` property is an object that maps each top-level selector in your styles to a unique, scoped class name:
+`css(styles)` returns a [`StyleSheet`](/docs/api.md#stylesheet) instance. Its `classes` property maps class name selectors to their generated unique class name:
```javascript
const sheet = css({
@@ -123,7 +123,7 @@ Generation happens once, when the instance is created — never during rendering
### Which selectors get a class
-Only top-level keys that are plain identifiers (matching `/^\w+$/` — letters, digits and underscores) get a generated class. At-rules (`@global`, `@media …`, `@keyframes …`), `$` references and keys with dashes, spaces or commas are left untouched and don't appear in `classes`.
+Keys matching `/^\w+$/` (letters, digits, underscore) at the top level and inside [at-rule blocks](#at-rules) get a generated class. The same name used in several places shares a single class.
### Stable across environments
@@ -363,6 +363,78 @@ css({
```
+#### At-rules
+
+- **Blocks** can declare classes directly — same rule as the top level, so a `@layer` needs no empty top-level
+ declaration. The same name used in several blocks shares one class:
+
+ ```javascript
+ const { classes } = css({
+ '@layer base' : {
+ button : {
+ color : 'black',
+ '&:hover' : {
+ color : 'blue'
+ }
+ }
+ },
+ '@layer theme' : {
+ button : {
+ color : 'red'
+ }
+ }
+ });
+
+ classes.button; // "fun-11ez4g7-button" — the same class in both layers
+ ```
+
+ ##### Renders to:
+ ```html
+
+ ```
+
+- **Statements** (at-rules without a block) are written as the at-rule name with its prelude as the value. The prelude is
+ emitted as it is written:
+
+ ```javascript
+ css({
+ '@import' : 'url("reset.css") layer(base)',
+ '@layer' : 'base, components, utilities',
+ button : {
+ color : 'red'
+ }
+ }).toString();
+ ```
+
+ ##### Renders to:
+ ```html
+
+ ```
+
+ Statements are rendered in the order the keys are written, so put `@import` first. Since object keys are unique,
+ a stylesheet holds one statement per at-rule name; `@import` takes a single URL, so use another `css()` call for
+ a second import.
+
**Example flow:**
```
Input styles object
@@ -605,7 +677,21 @@ sheet.classes.button; // string
sheet.classes.typo; // ❌ Property 'typo' does not exist
```
-Only top-level keys that are valid class-name identifiers (letters, digits and underscores — i.e. matching `/^\w+$/`) get a generated class at runtime, and the type mirrors that: at-rule keys (`@global`, `@keyframes …`, `@media …`, `@supports …`), class reference keys (`$name`) and keys containing selector syntax (dashes, spaces, `&`, `:`, etc.) are filtered out of `classes` automatically — they don't produce class names at runtime, so they don't appear in the type either.
+The type mirrors the runtime: keys matching `/^\w+$/` at the top level and inside [at-rule blocks](#at-rules).
+
+```ts
+const sheet = css({
+ '@layer base' : { button : { color : 'red' } },
+ '@layer theme' : { button : { color : 'blue' } },
+ '@layer' : 'base, theme'
+});
+
+sheet.classes.button; // string — one class shared by both layers
+```
+
+> **Note**: inside a block, a class named after a CSS property (`content`, `page`, `grid`, `flex`, …) doesn't type-check,
+> since the block's value type declares those keys as CSS properties. Declare that class at the top level and reference
+> it from the block with `$name`.
### CSS property autocomplete
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index 0b1407a..2468a2f 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -8,7 +8,7 @@ For detailed API documentation, see: [API Documentation](https://cdn.jsdelivr.ne
### Creating Styles
-The `css()` function creates and attaches a new StyleSheet instance to the DOM. It returns a StyleSheet instance with a `classes` object mapping original class names to generated unique class names.
+The `css()` function creates and attaches a new StyleSheet instance to the DOM. It returns a StyleSheet instance with a `classes` object mapping class name selectors to their generated unique class name.
```js
import { css } from 'cssfun';
@@ -36,7 +36,7 @@ const Button = () => ;
**Class Name Generation:**
- **When**: Generated at StyleSheet instance creation (when `css()` is called)
-- **Which**: Only top-level selectors matching valid class name patterns (alphanumeric, no special chars)
+- **Which**: Keys matching `/^\w+$/` at the top level and inside at-rule blocks. The same name used in several places shares one class
- **Format (Development)**: `{prefix}-{uid}-{className}` → `.fun-9qkk9s-button`
- **Format (Production)**: `{prefix[0]}-{uid}-{index}` → `.f-9qkk9s-1` (optimized for smaller bundle)
- **Access**: Via `classes` object: `classes.button` returns the generated class name
@@ -95,7 +95,8 @@ sheet.classes.button; // string
sheet.classes.typo; // ❌ Property 'typo' does not exist
```
-- Only top-level keys matching `/^\w+$/` (letters, digits, underscore) produce a class at runtime, and the type mirrors that: at-rule keys (`@global`, `@keyframes …`, `@media …`, `@supports …`), `$ref` keys and keys containing selector syntax (dashes, spaces, `&`, `:`, etc.) are filtered out of `classes` automatically — they don't produce class names at runtime, so they don't appear in the type either. Use simple identifiers for top-level class keys.
+- `classes` is typed from keys matching `/^\w+$/` at the top level and inside at-rule blocks
+- Inside a block, a class named after a CSS property (`content`, `page`, `grid`, `flex`, …) doesn't type-check — declare it at the top level and reference it with `$name`
### CSS property autocomplete
@@ -286,6 +287,44 @@ css({
// Renders to (prod): .f-9qkk9s-1 a { color: blue; }
```
+### At-rules
+
+At-rule blocks can declare classes directly (same rule as the top level). At-rules without a block are written as the at-rule name with its prelude as the value; the prelude is emitted as written.
+
+```js
+// ✅ Classes inside layer blocks — no empty top-level declaration needed
+const { classes } = css({
+ '@layer' : 'base, theme', // → @layer base, theme;
+ '@layer base' : {
+ button : {
+ color : 'black',
+ '&:hover' : { color : 'blue' }
+ }
+ },
+ '@layer theme' : {
+ button : { color : 'red' } // same generated class
+ }
+});
+
+// ✅ Statements: key is the at-rule name, value is the prelude
+css({
+ '@import' : 'url("reset.css") layer(base)', // → @import url("reset.css") layer(base);
+ '@charset' : '"utf-8"' // → @charset "utf-8";
+});
+
+// ✅ Overriding a class in a media query
+css({
+ button : { color : 'black' },
+ '@media (min-width: 768px)' : {
+ button : { color : 'blue' } // same class; `$button` also works
+ }
+});
+```
+
+**Key Points:**
+- Statements are rendered in the order the keys are written, so put `@import` first
+- Object keys are unique, so a stylesheet holds one statement per at-rule name; `@import` takes a single URL, so use another `css()` call for a second import
+
## 🎨 Themes
Themes provide CSS variables for consistent styling across your application. They support multiple color schemes (`light`, `dark`, `light dark`, `normal`) and automatically adapt to system preferences.
diff --git a/docs/api.md b/docs/api.md
index c36ceba..1ac8882 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -30,7 +30,7 @@ generates --fun-colors-primary : blue.
| Name | Type | Description |
| --- | --- | --- |
-| classes | Object | Object mapping each top-level selector key (those matching `/^\w+$/`) to its generated unique class name string. |
+| classes | Object | Map of class name selectors to their generated unique class name. |
| styles | Object | The original styles object provided to the instance. |
| uid | string | Unique identifier for the StyleSheet instance, generated using `this.generateUid`. |
| prefix | string | Prefix for generating unique identifiers. Resolved to a string when the instance is created (may be supplied as a function via options or a subclass). |
@@ -69,7 +69,7 @@ rendered as a string for server-side rendering.
| Param | Type | Default | Description |
| --- | --- | --- | --- |
-| styles | Object | | The styles object. This is an object where keys represent CSS selectors and values are style objects. The styles object is processed through the renderers to generate the final CSS string. It is stored in the instance as `this.styles`. |
+| styles | Object | | The styles object. This is an object where keys represent CSS selectors and values are style objects. An at-rule key may also hold a statement prelude string, rendered as `@rule prelude;`. The styles object is processed through the renderers to generate the final CSS string. It is stored in the instance as `this.styles`. |
| [options] | Object | {} | Configuration options. The following options are assigned to the instance (`this`): `prefix`, `generateUid`, `generateClassName`, `shouldAttachToDOM`, `attributes`, `renderers`. |
| [options.prefix] | string \| function | "'fun'" | Prefix for generating unique identifiers and data attributes. May be a function returning the prefix, evaluated when the instance is created. |
| [options.generateUid] | function | | Custom function to generate the unique identifier. |
@@ -299,11 +299,11 @@ Creates and attaches a new StyleSheet instance to the DOM.
**Kind**: global function
**Returns**: [StyleSheet](#StyleSheet) - The created and attached StyleSheet instance. Its `classes` property maps
-each top-level selector to its generated class name.
+class name selectors to their generated unique class name.
| Param | Type | Description |
| --- | --- | --- |
-| styles | Object | An object containing CSS rules. Keys represent selectors, and values represent style objects. |
+| styles | Object | An object containing CSS rules. Keys represent selectors, and values represent style objects. An at-rule key may also hold a statement prelude string, rendered as `@rule prelude;`. |
| [options] | Object | Optional configuration for the StyleSheet instance. Includes options like `prefix`, `renderers`, and more. |
**Example**
@@ -321,3 +321,23 @@ const { classes } = css({
// Use the generated `link` class in a component.
const Link = ({ label, href }) => {label};
```
+**Example**
+```js
+// Declare the layer order and define the classes inside layer blocks.
+const { classes } = css({
+ '@layer' : 'base, theme',
+ '@layer base' : {
+ button : {
+ color : 'black'
+ }
+ },
+ '@layer theme' : {
+ button : {
+ color : 'blue'
+ }
+ }
+});
+
+// Both blocks share the same generated `button` class.
+const Button = ({ label }) => ;
+```
diff --git a/src/StyleSheet.js b/src/StyleSheet.js
index 7b06a44..a5eb832 100644
--- a/src/StyleSheet.js
+++ b/src/StyleSheet.js
@@ -33,7 +33,8 @@ const styleSheetOptions = ['prefix', 'generateUid', 'generateClassName', 'should
* @module
* @class
* @param {Object} styles - The styles object. This is an object where keys represent
- * CSS selectors and values are style objects. The styles object is processed through
+ * CSS selectors and values are style objects. An at-rule key may also hold a statement
+ * prelude string, rendered as `@rule prelude;`. The styles object is processed through
* the renderers to generate the final CSS string. It is stored in the instance as `this.styles`.
* @param {Object} [options={}] - Configuration options. The following options are assigned to the instance (`this`):
* `prefix`, `generateUid`, `generateClassName`, `shouldAttachToDOM`, `attributes`, `renderers`.
@@ -67,8 +68,7 @@ const styleSheetOptions = ['prefix', 'generateUid', 'generateClassName', 'should
* return
Hello World
;
* }
*
- * @property {Object} classes - Object mapping each top-level selector key (those matching `/^\w+$/`)
- * to its generated unique class name string.
+ * @property {Object} classes - Map of class name selectors to their generated unique class name.
* @property {Object} styles - The original styles object provided to the instance.
* @property {string} uid - Unique identifier for the StyleSheet instance, generated using `this.generateUid`.
* @property {string} prefix - Prefix for generating unique identifiers. Resolved to a string when the instance
@@ -98,13 +98,21 @@ class StyleSheet {
this.prefix = this.prefix ? getResult(this.prefix, this) : StyleSheet.prefix;
// Generate the `StyleSheet` unique identifier.
this.uid = this.generateUid();
- // Generate class names. Only generate class names for top-level selectors.
+ // Generate class names, descending into at-rule blocks that nest style rules.
let counter = 0;
- Object.keys(styles).forEach(selector => {
- if (selector.match(StyleSheet.classRegex)) {
- this.classes[selector] = this.generateClassName(selector, ++counter);
- }
- });
+ const generateClasses = styles => {
+ Object.keys(styles).forEach(selector => {
+ if (selector in this.classes || !isObject(styles[selector])) return;
+ if (selector.match(StyleSheet.classRegex)) {
+ // Generate a class name for the selector.
+ this.classes[selector] = this.generateClassName(selector, ++counter);
+ } else if (selector.match(StyleSheet.atBlockRegex)) {
+ // Descend into at-rule blocks that nest style rules.
+ generateClasses(styles[selector]);
+ }
+ });
+ };
+ generateClasses(styles);
}
/**
@@ -191,8 +199,10 @@ class StyleSheet {
acc.push(`${indent}${key}${whitespace}{${nl}${renderedStyles}${indent}}${nl}`);
}
} else if (typeof value !== 'undefined' && value !== null) {
- // Add the style to the accumulator.
- acc.push(`${indent}${key}:${whitespace}${value};${nl}`);
+ // At-rule keys are separated by a space, other keys are separated by a colon.
+ const separator = key.match(StyleSheet.atRuleRegex) ? ' ' : `:${whitespace}`;
+ // Add the at-rule statement or the style to the accumulator.
+ acc.push(`${indent}${key}${separator}${value};${nl}`);
}
return acc;
@@ -225,9 +235,10 @@ class StyleSheet {
return `${parentSelector ? `${parentSelector} ` : ''}${key.replace(StyleSheet.globalPrefixRegex, '')}`;
}
// Nested, references and replace class names with created ones.
+ // Missing parent becomes '' so `&:hover` renders as `:hover`, not `undefined:hover`.
return fromClasses(key)
.replace(StyleSheet.referenceRegex, (_match, ref) => fromClasses(ref))
- .replace(StyleSheet.nestedRegex, parentSelector);
+ .replace(StyleSheet.nestedRegex, parentSelector || '');
};
const result = Object.keys(styles).reduce((acc, key) => {
@@ -392,6 +403,22 @@ class StyleSheet {
*/
StyleSheet.classRegex = /^\w+$/;
+/**
+ * Regular expression to match at-rules.
+ * @static
+ * @private
+ */
+StyleSheet.atRuleRegex = /^@/;
+
+/**
+ * At-rules whose block nests style rules (and may therefore declare classes).
+ * Other at-blocks (`@property`, `@page`, `@font-face`, `@keyframes`, …) are skipped
+ * so their descriptor keys are not treated as class names.
+ * @static
+ * @private
+ */
+StyleSheet.atBlockRegex = /^@(media|supports|layer|container|scope|starting-style)\b/;
+
/**
* Regular expression to match global styles.
* @static
diff --git a/src/css.js b/src/css.js
index 16871d2..5a8eb5f 100644
--- a/src/css.js
+++ b/src/css.js
@@ -6,9 +6,10 @@ import StyleSheet from './StyleSheet.js';
* @module
* @function
* @param {Object} styles - An object containing CSS rules. Keys represent selectors, and values represent style objects.
+ * An at-rule key may also hold a statement prelude string, rendered as `@rule prelude;`.
* @param {Object} [options] - Optional configuration for the StyleSheet instance. Includes options like `prefix`, `renderers`, and more.
* @returns {StyleSheet} The created and attached StyleSheet instance. Its `classes` property maps
- * each top-level selector to its generated class name.
+ * class name selectors to their generated unique class name.
*
* @example
* // Create styles for a link component.
@@ -23,6 +24,25 @@ import StyleSheet from './StyleSheet.js';
*
* // Use the generated `link` class in a component.
* const Link = ({ label, href }) => {label};
+ *
+ * @example
+ * // Declare the layer order and define the classes inside layer blocks.
+ * const { classes } = css({
+ * '@layer' : 'base, theme',
+ * '@layer base' : {
+ * button : {
+ * color : 'black'
+ * }
+ * },
+ * '@layer theme' : {
+ * button : {
+ * color : 'blue'
+ * }
+ * }
+ * });
+ *
+ * // Both blocks share the same generated `button` class.
+ * const Button = ({ label }) => ;
*/
const css = (styles, options) => new StyleSheet(styles, options).attach();
diff --git a/test-d/index.test-d.ts b/test-d/index.test-d.ts
index 4196d0f..a53334c 100644
--- a/test-d/index.test-d.ts
+++ b/test-d/index.test-d.ts
@@ -29,6 +29,38 @@ const mixed = css({
// only plain class-name keys are exposed; at-rules and $refs are filtered out
expectType<{ readonly root: string; readonly button: string }>(mixed.classes);
+/*
+ * css(): classes declared inside at-rule blocks are included, and the same
+ * name used in several blocks shares one class
+ */
+const layered = css({
+ '@layer base' : { button : { color : 'red' }, card : {} },
+ '@layer theme' : { button : { color : 'blue' } },
+ '@media (min-width: 768px)' : { panel : {} },
+ '@supports (color: red)' : { '@container (min-width: 400px)' : { nested : {} } },
+ '@keyframes wave' : { from : { opacity : 0 } },
+ '@global' : { body : { margin : 0 } },
+ '@layer' : 'base, theme',
+ root : {},
+});
+expectType<{
+ readonly button: string;
+ readonly card: string;
+ readonly panel: string;
+ readonly nested: string;
+ readonly root: string;
+}>(layered.classes);
+
+/*
+ * css(): keys holding a plain value never produce a class, at any level
+ */
+const plainValues = css({
+ '@media (min-width: 768px)' : { color : 'red' },
+ '@layer' : 'base',
+ root : { color : 'blue' },
+});
+expectType<{ readonly root: string }>(plainValues.classes);
+
/*
* css(): keys that don't match /^\w+$/ are excluded from `classes`,
* matching the runtime (no class name is generated for them)
@@ -178,6 +210,8 @@ expectError(createTheme({}, { colorScheme : 'invalid' }));
* type exports are usable
*/
const styles : Styles = { root : { color : 'red' } };
+// at-rule statements are valid top-level entries
+const stylesWithStatements : Styles = { '@layer' : 'base, utilities', root : { color : 'red' } };
const rule : StyleRule = { color : 'blue', '&:hover' : { color : 'red' } };
const value : CSSValue = 'blue';
const props : CSSProperties = { color : 'blue', backgroundColor : null, padding : 10 };
diff --git a/test/index.test.js b/test/index.test.js
index 322b6bf..10219af 100644
--- a/test/index.test.js
+++ b/test/index.test.js
@@ -301,6 +301,136 @@ describe('cssfun', () => {
expect(style.outerHTML).to.be.equal(``);
});
+ it('must render at-rule statements', () => {
+ const instance = new StyleSheet({
+ '@import' : 'url("reset.css") layer(base) supports(display: grid)',
+ '@layer' : 'base, components, utilities',
+ root : {
+ color : 'red'
+ }
+ });
+ expect(instance.render()).to.be.equal(`@import url("reset.css") layer(base) supports(display: grid);@layer base, components, utilities;.${instance.prefix[0]}-${instance.uid}-1{color:red;}`);
+ });
+
+ it('must generate class names inside at-rule blocks', () => {
+ const instance = css({
+ '@layer base' : {
+ button : {
+ color : 'red',
+ '&:hover' : {
+ color : 'blue'
+ }
+ }
+ }
+ });
+ const cls = instance.classes.button;
+ expect(cls).to.exist;
+ expect(instance.el.textContent).to.be.equal(`@layer base{.${cls}{color:red;}.${cls}:hover{color:blue;}}`);
+ });
+
+ it('must share a class name between at-rule blocks', () => {
+ const instance = css({
+ '@layer base' : {
+ button : {
+ color : 'red'
+ }
+ },
+ '@layer theme' : {
+ button : {
+ color : 'green'
+ }
+ }
+ });
+ const cls = instance.classes.button;
+ expect(Object.keys(instance.classes)).to.deep.equal(['button']);
+ expect(instance.el.textContent).to.be.equal(`@layer base{.${cls}{color:red;}}@layer theme{.${cls}{color:green;}}`);
+ });
+
+ it('must generate class names inside nested at-rule blocks', () => {
+ const instance = css({
+ '@layer base' : {
+ '@media (min-width: 768px)' : {
+ '@supports (color: red)' : {
+ button : {
+ color : 'red'
+ }
+ }
+ }
+ }
+ });
+ const cls = instance.classes.button;
+ expect(instance.el.textContent).to.be.equal(`@layer base{@media (min-width: 768px){@supports (color: red){.${cls}{color:red;}}}}`);
+ });
+
+ it('must not generate class names inside at-rule blocks that hold no style rules', () => {
+ const instance = css({
+ '@keyframes wave' : {
+ from : {
+ opacity : 0
+ },
+ to : {
+ opacity : 1
+ }
+ },
+ '@property --my-color' : {
+ syntax : '""',
+ inherits : false,
+ initialValue : 'red'
+ },
+ '@page' : {
+ margin : '1cm'
+ },
+ '@layer base' : {
+ '@global' : {
+ body : {
+ margin : 0
+ }
+ }
+ },
+ root : {
+ animation : 'wave 1s'
+ }
+ });
+ expect(Object.keys(instance.classes)).to.deep.equal(['root']);
+ expect(instance.el.textContent).to.be.equal(
+ '@keyframes wave{from{opacity:0;}to{opacity:1;}}' +
+ '@property --my-color{syntax:"";inherits:false;initial-value:red;}' +
+ '@page{margin:1cm;}' +
+ '@layer base{body{margin:0;}}' +
+ `.${instance.classes.root}{animation:wave 1s;}`
+ );
+ });
+
+ it('must not generate a class name for a key holding a plain value', () => {
+ const instance = new StyleSheet({
+ '@media (min-width: 768px)' : {
+ color : 'red'
+ },
+ foo : 'bar',
+ root : {
+ color : 'blue'
+ }
+ });
+ expect(Object.keys(instance.classes)).to.deep.equal(['root']);
+ });
+
+ it('must expand & inside a class declared in an at-rule block', () => {
+ const instance = css({
+ button : {
+ color : 'red'
+ },
+ '@media (min-width: 768px)' : {
+ button : {
+ '&:hover' : {
+ color : 'blue'
+ }
+ }
+ }
+ });
+ const cls = instance.classes.button;
+ expect(instance.el.textContent).to.be.equal(`.${cls}{color:red;}@media (min-width: 768px){.${cls}:hover{color:blue;}}`);
+ });
+
it('must render css variables', () => {
const instance = css({
root : {
diff --git a/types/StyleSheet.d.ts b/types/StyleSheet.d.ts
index 27d7975..e5efd7d 100644
--- a/types/StyleSheet.d.ts
+++ b/types/StyleSheet.d.ts
@@ -22,8 +22,12 @@ export interface StyleRule extends CSSProperties {
[selector: string]: StyleRule | CSSValue;
}
-/** Top-level styles object mapping class names and selectors to style rules. */
-export type Styles = Record;
+/**
+ * Top-level styles object mapping class names and selectors to style rules.
+ * An at-rule key may also hold a statement prelude, rendered as
+ * `@rule prelude;` (e.g. `'@layer' : 'base, utilities'`).
+ */
+export type Styles = Record;
/** A renderer function: receives the current value and returns the next, called with the StyleSheet as `this`. */
export type RendererFn = (this: StyleSheet, styles: any) => any;
@@ -32,14 +36,33 @@ export type RendererFn = (this: StyleSheet, styles: any) => any;
export type Resolvable = T | ((this: StyleSheet) => T);
/**
- * Characters that can't appear in a top-level class key. At runtime only keys
- * matching `/^\w+$/` produce a class name; keys containing any selector
- * syntax (dashes, spaces, combinators, at-rules, references, etc.) don't.
+ * Characters that can't appear in a class key. At runtime only keys matching
+ * `/^\w+$/` produce a class name.
*/
type InvalidClassChar =
| '-' | ' ' | '.' | ',' | ':' | '&' | '>' | '+' | '~'
| '*' | '[' | ']' | '(' | ')' | '#' | '@' | '$' | '%' | '"' | "'" | '=' | '|' | '^';
+/** At-rules whose block nests style rules, and may therefore declare class names. */
+type AtBlockPrefix = '@media' | '@supports' | '@layer' | '@container' | '@scope' | '@starting-style';
+
+/** Own keys that produce a class name: plain identifiers whose value is a rule object. */
+type OwnClassKeys = keyof {
+ [K in keyof S as K extends `${string}${InvalidClassChar}${string}` ? never
+ : S[K] extends CSSValue ? never
+ : K & string]: unknown;
+};
+
+/** Keys that produce a class name inside at-rule blocks that nest style rules. */
+type NestedClassKeys = {
+ [K in keyof S]: K extends `${AtBlockPrefix}${string}`
+ ? (S[K] extends CSSValue ? never : ClassKeys)
+ : never;
+}[keyof S];
+
+/** Every key that produces a class name, collected recursively through at-rule blocks. */
+type ClassKeys = OwnClassKeys | NestedClassKeys;
+
/** Options for the StyleSheet constructor. Accepts custom keys for subclasses and custom renderers. */
export interface StyleSheetOptions {
/**
@@ -88,16 +111,9 @@ declare class StyleSheet {
*/
preinitialize(styles: S, options?: StyleSheetOptions): void;
- /**
- * Object mapping each top-level selector key (those matching `/^\w+$/`) to its
- * generated unique class name string.
- * Keys containing selector syntax — at-rules (`@global`, `@keyframes …`,
- * `@media …`, `@supports …`), class references (`$name`) and keys with
- * dashes, spaces or other special characters — are excluded, matching
- * the runtime behavior: they don't produce class names.
- */
+ /** Map of class name selectors to their generated unique class name. */
readonly classes: {
- readonly [K in keyof S as K extends `${string}${InvalidClassChar}${string}` ? never : K & string]: string;
+ readonly [K in Extract, string>]: string;
};
/** The original styles object provided to the instance. */
styles: S;
@@ -164,6 +180,10 @@ declare class StyleSheet {
/** Regular expression to match class names. */
static classRegex: RegExp;
+ /** Regular expression to match at-rules. */
+ static atRuleRegex: RegExp;
+ /** Regular expression to match at-rules whose block may declare class names. */
+ static atBlockRegex: RegExp;
/** Regular expression to match global styles. */
static globalRegex: RegExp;
/** Regular expression to match global styles with a prefix. */
diff --git a/types/css.d.ts b/types/css.d.ts
index 5626878..4a90e52 100644
--- a/types/css.d.ts
+++ b/types/css.d.ts
@@ -6,9 +6,10 @@ import type { Styles, StyleSheetOptions } from './StyleSheet.js';
*
* @template S - The styles object type. Used to infer the keys of the `classes` property.
* @param styles - An object containing CSS rules. Keys represent selectors, values represent style objects.
+ * An at-rule key may also hold a statement prelude string, rendered as `@rule prelude;`.
* @param options - Optional configuration for the StyleSheet instance.
* @returns The created and attached StyleSheet instance. Its `classes` property maps
- * each top-level selector to its generated class name.
+ * class name selectors to their generated unique class name.
*/
declare function css(styles: S, options?: StyleSheetOptions): StyleSheet;
From 01ac315305f452894587565be81a913db0788212 Mon Sep 17 00:00:00 2001
From: Alberto Masuelli
Date: Tue, 4 Aug 2026 15:17:16 -0300
Subject: [PATCH 2/7] perf(types): bound ClassKeys depth to avoid TS server
blowup
---
types/StyleSheet.d.ts | 27 ++++++++++++++++++++-------
1 file changed, 20 insertions(+), 7 deletions(-)
diff --git a/types/StyleSheet.d.ts b/types/StyleSheet.d.ts
index e5efd7d..6d245a1 100644
--- a/types/StyleSheet.d.ts
+++ b/types/StyleSheet.d.ts
@@ -53,15 +53,28 @@ type OwnClassKeys = keyof {
: K & string]: unknown;
};
-/** Keys that produce a class name inside at-rule blocks that nest style rules. */
-type NestedClassKeys = {
+/**
+ * Every key that produces a class name: an object's own class keys, plus the class
+ * keys declared inside at-rule blocks that nest style rules (`@media`, `@layer`, …),
+ * gathered recursively through those blocks.
+ *
+ * The descent is unrolled into fixed levels (`ClassKeys` → `ClassKeys1` → `ClassKeys2`)
+ * rather than written as a single self-referential type. The bounded depth (three
+ * levels of at-rule nesting) keeps type-checking cheap and stops the compiler's
+ * recursion budget from blowing up on large stylesheets.
+ */
+type ClassKeys = OwnClassKeys | {
[K in keyof S]: K extends `${AtBlockPrefix}${string}`
- ? (S[K] extends CSSValue ? never : ClassKeys)
- : never;
+ ? (S[K] extends CSSValue ? never : ClassKeys1) : never;
+}[keyof S];
+type ClassKeys1 = OwnClassKeys | {
+ [K in keyof S]: K extends `${AtBlockPrefix}${string}`
+ ? (S[K] extends CSSValue ? never : ClassKeys2) : never;
+}[keyof S];
+type ClassKeys2 = OwnClassKeys | {
+ [K in keyof S]: K extends `${AtBlockPrefix}${string}`
+ ? (S[K] extends CSSValue ? never : OwnClassKeys) : never;
}[keyof S];
-
-/** Every key that produces a class name, collected recursively through at-rule blocks. */
-type ClassKeys = OwnClassKeys | NestedClassKeys;
/** Options for the StyleSheet constructor. Accepts custom keys for subclasses and custom renderers. */
export interface StyleSheetOptions {
From 5d1d295865ad8f9d626f41b210ff99239b12da6c Mon Sep 17 00:00:00 2001
From: Alberto Masuelli
Date: Tue, 4 Aug 2026 16:43:48 -0300
Subject: [PATCH 3/7] feat: support array values for repeated statements and
fallback declarations
An array value emits one statement/declaration per element, so an at-rule
name can repeat (e.g. several `@import` rules) and properties can carry CSS
fallback values.
---
CHANGELOG.md | 11 ++++++++++-
README.md | 21 ++++++++++++++++++---
docs/api.md | 4 ++--
src/StyleSheet.js | 15 +++++++++++----
src/css.js | 3 ++-
test-d/index.test-d.ts | 2 ++
test/index.test.js | 19 +++++++++++++++++++
types/StyleSheet.d.ts | 17 ++++++++++++-----
8 files changed, 76 insertions(+), 16 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 766fa38..4c5ee9b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,7 +18,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
});
```
- Object keys are unique, so a stylesheet holds one statement per at-rule name.
+ Object keys are unique, but an array value emits one statement per element, so a name can repeat — several `@import` rules, for instance. The same array form provides CSS fallback values for a property.
+
+ ```javascript
+ css({
+ '@import' : ['url("reset.css")', 'url("theme.css") layer(base)'], // two @import rules
+ root : {
+ color : ['#eee', 'var(--bg)'] // color:#eee; color:var(--bg);
+ }
+ });
+ ```
- Class names are now generated inside at-rule blocks as well as at the top level, so a class can be declared directly inside a `@layer` (or `@media`, …) instead of being declared empty at the top level. The same name used in several places shares a single class.
diff --git a/README.md b/README.md
index 0b666eb..948a086 100644
--- a/README.md
+++ b/README.md
@@ -431,9 +431,24 @@ css({
```
- Statements are rendered in the order the keys are written, so put `@import` first. Since object keys are unique,
- a stylesheet holds one statement per at-rule name; `@import` takes a single URL, so use another `css()` call for
- a second import.
+ Object keys are unique, but an array value emits one statement per element, so a name can repeat — for example, several `@import` rules:
+
+ ```javascript
+ css({
+ '@import' : ['url("reset.css")', 'url("theme.css") layer(base)']
+ }).toString();
+ ```
+
+ ##### Renders to:
+ ```html
+
+ ```
+
+ The same array form applies to properties, providing CSS fallback values (`color : ['#eee', 'var(--bg)']` renders
+ `color: #eee;` followed by `color: var(--bg);`).
**Example flow:**
```
diff --git a/docs/api.md b/docs/api.md
index 1ac8882..6d0ac25 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -69,7 +69,7 @@ rendered as a string for server-side rendering.
| Param | Type | Default | Description |
| --- | --- | --- | --- |
-| styles | Object | | The styles object. This is an object where keys represent CSS selectors and values are style objects. An at-rule key may also hold a statement prelude string, rendered as `@rule prelude;`. The styles object is processed through the renderers to generate the final CSS string. It is stored in the instance as `this.styles`. |
+| styles | Object | | The styles object. This is an object where keys represent CSS selectors and values are style objects. An at-rule key may also hold a statement prelude string, rendered as `@rule prelude;`; an array value emits one statement per element, so a name can repeat (e.g. several `@import` rules) and properties can carry fallback values. The styles object is processed through the renderers to generate the final CSS string. It is stored in the instance as `this.styles`. |
| [options] | Object | {} | Configuration options. The following options are assigned to the instance (`this`): `prefix`, `generateUid`, `generateClassName`, `shouldAttachToDOM`, `attributes`, `renderers`. |
| [options.prefix] | string \| function | "'fun'" | Prefix for generating unique identifiers and data attributes. May be a function returning the prefix, evaluated when the instance is created. |
| [options.generateUid] | function | | Custom function to generate the unique identifier. |
@@ -303,7 +303,7 @@ class name selectors to their generated unique class name.
| Param | Type | Description |
| --- | --- | --- |
-| styles | Object | An object containing CSS rules. Keys represent selectors, and values represent style objects. An at-rule key may also hold a statement prelude string, rendered as `@rule prelude;`. |
+| styles | Object | An object containing CSS rules. Keys represent selectors, and values represent style objects. An at-rule key may also hold a statement prelude string, rendered as `@rule prelude;`; an array value emits one statement per element, so a name can repeat (e.g. several `@import` rules) and properties can carry fallback values. |
| [options] | Object | Optional configuration for the StyleSheet instance. Includes options like `prefix`, `renderers`, and more. |
**Example**
diff --git a/src/StyleSheet.js b/src/StyleSheet.js
index a5eb832..628ae88 100644
--- a/src/StyleSheet.js
+++ b/src/StyleSheet.js
@@ -33,8 +33,10 @@ const styleSheetOptions = ['prefix', 'generateUid', 'generateClassName', 'should
* @module
* @class
* @param {Object} styles - The styles object. This is an object where keys represent
- * CSS selectors and values are style objects. An at-rule key may also hold a statement
- * prelude string, rendered as `@rule prelude;`. The styles object is processed through
+ * CSS selectors and values are style objects. An at-rule key may also hold a statement
+ * prelude string, rendered as `@rule prelude;`; an array value emits one statement per
+ * element, so a name can repeat (e.g. several `@import` rules) and properties can carry
+ * fallback values. The styles object is processed through
* the renderers to generate the final CSS string. It is stored in the instance as `this.styles`.
* @param {Object} [options={}] - Configuration options. The following options are assigned to the instance (`this`):
* `prefix`, `generateUid`, `generateClassName`, `shouldAttachToDOM`, `attributes`, `renderers`.
@@ -201,8 +203,13 @@ class StyleSheet {
} else if (typeof value !== 'undefined' && value !== null) {
// At-rule keys are separated by a space, other keys are separated by a colon.
const separator = key.match(StyleSheet.atRuleRegex) ? ' ' : `:${whitespace}`;
- // Add the at-rule statement or the style to the accumulator.
- acc.push(`${indent}${key}${separator}${value};${nl}`);
+ // An array value emits one statement/declaration per element, allowing
+ // repeated keys such as multiple `@import` rules or CSS fallback values.
+ (Array.isArray(value) ? value : [value]).forEach(v => {
+ if (typeof v !== 'undefined' && v !== null) {
+ acc.push(`${indent}${key}${separator}${v};${nl}`);
+ }
+ });
}
return acc;
diff --git a/src/css.js b/src/css.js
index 5a8eb5f..6c7ea5f 100644
--- a/src/css.js
+++ b/src/css.js
@@ -6,7 +6,8 @@ import StyleSheet from './StyleSheet.js';
* @module
* @function
* @param {Object} styles - An object containing CSS rules. Keys represent selectors, and values represent style objects.
- * An at-rule key may also hold a statement prelude string, rendered as `@rule prelude;`.
+ * An at-rule key may also hold a statement prelude string, rendered as `@rule prelude;`; an array value emits one
+ * statement per element, so a name can repeat (e.g. several `@import` rules) and properties can carry fallback values.
* @param {Object} [options] - Optional configuration for the StyleSheet instance. Includes options like `prefix`, `renderers`, and more.
* @returns {StyleSheet} The created and attached StyleSheet instance. Its `classes` property maps
* class name selectors to their generated unique class name.
diff --git a/test-d/index.test-d.ts b/test-d/index.test-d.ts
index a53334c..831b3cf 100644
--- a/test-d/index.test-d.ts
+++ b/test-d/index.test-d.ts
@@ -212,6 +212,8 @@ expectError(createTheme({}, { colorScheme : 'invalid' }));
const styles : Styles = { root : { color : 'red' } };
// at-rule statements are valid top-level entries
const stylesWithStatements : Styles = { '@layer' : 'base, utilities', root : { color : 'red' } };
+// an array value repeats a statement (multiple `@import` rules) or declaration (fallback values)
+const stylesWithArrays : Styles = { '@import' : ['url("a.css")', 'url("b.css")'], root : { color : ['#eee', 'var(--bg)'] } };
const rule : StyleRule = { color : 'blue', '&:hover' : { color : 'red' } };
const value : CSSValue = 'blue';
const props : CSSProperties = { color : 'blue', backgroundColor : null, padding : 10 };
diff --git a/test/index.test.js b/test/index.test.js
index 10219af..a90abe0 100644
--- a/test/index.test.js
+++ b/test/index.test.js
@@ -312,6 +312,25 @@ describe('cssfun', () => {
expect(instance.render()).to.be.equal(`@import url("reset.css") layer(base) supports(display: grid);@layer base, components, utilities;.${instance.prefix[0]}-${instance.uid}-1{color:red;}`);
});
+ it('must render an array value as repeated statements', () => {
+ const instance = new StyleSheet({
+ '@import' : ['url("reset.css")', 'url("theme.css") layer(base)'],
+ root : {
+ color : 'red'
+ }
+ });
+ expect(instance.render()).to.be.equal(`@import url("reset.css");@import url("theme.css") layer(base);.${instance.prefix[0]}-${instance.uid}-1{color:red;}`);
+ });
+
+ it('must render an array value as fallback declarations', () => {
+ const instance = new StyleSheet({
+ root : {
+ color : ['#eee', 'var(--bg)']
+ }
+ });
+ expect(instance.render()).to.be.equal(`.${instance.prefix[0]}-${instance.uid}-1{color:#eee;color:var(--bg);}`);
+ });
+
it('must generate class names inside at-rule blocks', () => {
const instance = css({
'@layer base' : {
diff --git a/types/StyleSheet.d.ts b/types/StyleSheet.d.ts
index 6d245a1..6e5828c 100644
--- a/types/StyleSheet.d.ts
+++ b/types/StyleSheet.d.ts
@@ -3,14 +3,19 @@ import type { Properties } from 'csstype';
/** A CSS property value. */
export type CSSValue = string | number | null | undefined;
+/** csstype's value union for property `K`, with `string & {}` for arbitrary strings. */
+type PropValue> =
+ Properties<(string & {}) | number, string & {}>[K];
+
/**
* CSS properties with full value autocomplete. Uses csstype's original value
* unions (which include `string & {}` for arbitrary strings) plus `null`
- * (filtered at runtime). Numbers accepted for length properties.
+ * (filtered at runtime). Numbers accepted for length properties. An array value
+ * emits one declaration per element, providing CSS fallback values.
*/
export type CSSProperties = {
[K in keyof Properties<(string & {}) | number, string & {}>]?:
- Properties<(string & {}) | number, string & {}>[K] | null;
+ PropValue | null | PropValue[];
};
/**
@@ -19,15 +24,17 @@ export type CSSProperties = {
* and class references (`$className`).
*/
export interface StyleRule extends CSSProperties {
- [selector: string]: StyleRule | CSSValue;
+ [selector: string]: StyleRule | CSSValue | CSSValue[];
}
/**
* Top-level styles object mapping class names and selectors to style rules.
* An at-rule key may also hold a statement prelude, rendered as
- * `@rule prelude;` (e.g. `'@layer' : 'base, utilities'`).
+ * `@rule prelude;` (e.g. `'@layer' : 'base, utilities'`). An array of preludes
+ * emits one statement per element, so a name can repeat (e.g. several
+ * `'@import'` rules).
*/
-export type Styles = Record;
+export type Styles = Record;
/** A renderer function: receives the current value and returns the next, called with the StyleSheet as `this`. */
export type RendererFn = (this: StyleSheet, styles: any) => any;
From 369dcaac2f957af4f78d40cbb6593eaa669a8a21 Mon Sep 17 00:00:00 2001
From: Alberto Masuelli
Date: Wed, 5 Aug 2026 15:33:24 -0300
Subject: [PATCH 4/7] 0.2.0-alpha.0
---
README.md | 4 ++--
docs/AGENTS.md | 14 +++++++-------
example/rasti/index.html | 2 +-
example/react/index.html | 2 +-
example/vanilla/index.html | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
7 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index 948a086..d875a7d 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-
-
+
+
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index 2468a2f..c1882b5 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -2,7 +2,7 @@
This document provides a comprehensive reference for AI agents working with the CSSFUN library. It covers the core API patterns, style syntax, theme management, and best practices.
-For detailed API documentation, see: [API Documentation](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.1.0/docs/api.md)
+For detailed API documentation, see: [API Documentation](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.2.0-alpha.0/docs/api.md)
## 🔧 Core API
@@ -42,7 +42,7 @@ const Button = () => ;
- **Access**: Via `classes` object: `classes.button` returns the generated class name
**Related API:**
-- [`css(styles, [options])`](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.1.0/docs/api.md#css) - Creates and attaches a StyleSheet
+- [`css(styles, [options])`](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.2.0-alpha.0/docs/api.md#css) - Creates and attaches a StyleSheet
### Style Object Structure
@@ -455,7 +455,7 @@ const theme = createTheme({
```
**Related API:**
-- [`createTheme(themes, [options])`](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.1.0/docs/api.md#createtheme) - Creates a theme StyleSheet
+- [`createTheme(themes, [options])`](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.2.0-alpha.0/docs/api.md#createtheme) - Creates a theme StyleSheet
## 🚀 Server-Side Rendering (SSR)
@@ -519,8 +519,8 @@ const css = StyleSheet.toCSS();
```
**Related API:**
-- [`StyleSheet.toString()`](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.1.0/docs/api.md#stylesheet_tostring) - Static method to render all instances as HTML
-- [`StyleSheet.toCSS()`](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.1.0/docs/api.md#stylesheet_tocss) - Static method to render all instances as CSS
+- [`StyleSheet.toString()`](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.2.0-alpha.0/docs/api.md#stylesheet_tostring) - Static method to render all instances as HTML
+- [`StyleSheet.toCSS()`](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.2.0-alpha.0/docs/api.md#stylesheet_tocss) - Static method to render all instances as CSS
## 🛠️ Advanced Usage
@@ -591,7 +591,7 @@ const { classes } = css({
```
**Related API:**
-- [`StyleSheet`](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.1.0/docs/api.md#stylesheet) - StyleSheet class documentation
+- [`StyleSheet`](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.2.0-alpha.0/docs/api.md#stylesheet) - StyleSheet class documentation
## ⚠️ CSSFUN Best Practices
@@ -683,6 +683,6 @@ const Button = ({ children, disabled, onClick }) => (
## Additional Resources
-- **Full API Documentation**: [api.md](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.1.0/docs/api.md)
+- **Full API Documentation**: [api.md](https://cdn.jsdelivr.net/gh/8tentaculos/cssfun@v0.2.0-alpha.0/docs/api.md)
- **GitHub Repository**: [8tentaculos/cssfun](https://github.com/8tentaculos/cssfun)
- **Examples**: Check the `example/` folder in the repository
diff --git a/example/rasti/index.html b/example/rasti/index.html
index 27535aa..0b7c14b 100644
--- a/example/rasti/index.html
+++ b/example/rasti/index.html
@@ -9,7 +9,7 @@
{
"imports": {
"rasti": "https://esm.run/rasti@4.0.1",
- "cssfun": "https://esm.run/cssfun@0.1.0"
+ "cssfun": "https://esm.run/cssfun@0.2.0-alpha.0"
}
}
diff --git a/example/react/index.html b/example/react/index.html
index 659b2a5..73eeb93 100644
--- a/example/react/index.html
+++ b/example/react/index.html
@@ -12,7 +12,7 @@
"react-dom": "https://esm.sh/react-dom@19",
"react-dom/client": "https://esm.sh/react-dom@19/client",
"htm": "https://esm.sh/htm@3.1.0",
- "cssfun": "https://esm.run/cssfun@0.1.0"
+ "cssfun": "https://esm.run/cssfun@0.2.0-alpha.0"
}
}
diff --git a/example/vanilla/index.html b/example/vanilla/index.html
index 648572d..db12c61 100644
--- a/example/vanilla/index.html
+++ b/example/vanilla/index.html
@@ -8,7 +8,7 @@
diff --git a/package-lock.json b/package-lock.json
index 3086b96..e017c97 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "cssfun",
- "version": "0.1.0",
+ "version": "0.2.0-alpha.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cssfun",
- "version": "0.1.0",
+ "version": "0.2.0-alpha.0",
"license": "MIT",
"dependencies": {
"csstype": "3.2.3"
diff --git a/package.json b/package.json
index 31cc57e..95331f0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cssfun",
- "version": "0.1.0",
+ "version": "0.2.0-alpha.0",
"description": "Near-zero runtime CSS-in-JS library",
"type": "module",
"main": "lib/index.cjs",
From 5fc5c3b5b67d506d77c07f8fb4d74ffa6f03f2a5 Mon Sep 17 00:00:00 2001
From: Alberto Masuelli
Date: Fri, 14 Aug 2026 19:04:29 -0300
Subject: [PATCH 5/7] fix(ssr): escape the markup produced by toString
`toString()` built the `` element: a ``.
+
+ This only ever affected server-side rendering. The DOM path used by `attach()` is unchanged and was never affected, since it sets `textContent` and uses `setAttribute`.
## [0.1.0] - 2026-07-25
diff --git a/docs/api.md b/docs/api.md
index 6d0ac25..fa2d8b6 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -148,6 +148,10 @@ It will return a string ready to be added to the style element.
### styleSheet.toString() ⇒ string
Render the StyleSheet as a style element string.
Used for server-side rendering.
+The result is markup, so it is escaped to stay a single well formed `StyleSheet](#StyleSheet)
**Returns**: string - The instance as a string.
diff --git a/src/StyleSheet.js b/src/StyleSheet.js
index 628ae88..61fa56a 100644
--- a/src/StyleSheet.js
+++ b/src/StyleSheet.js
@@ -22,6 +22,39 @@ const getResult = (expression, context) =>
typeof expression !== 'function' ? expression :
expression.call(context);
+/**
+ * Match a name that is legal for an HTML attribute. Whitespace, quotes, `>`, `/` and `=`
+ * end the name and start another attribute when it is interpolated into markup. Control
+ * characters are excluded to match `setAttribute`, which rejects them.
+ * @private
+ */
+// eslint-disable-next-line no-control-regex
+const validAttributeNameRegex = /^[^\s"'>/=\u0000-\u001F\u007F-\u009F]+$/;
+
+/**
+ * Escape a value so it can be interpolated inside a double quoted HTML attribute.
+ * Escapes what the HTML serializer escapes in an attribute value, so the result matches
+ * what the DOM produces for the same value: the quote, which would end the value, and
+ * `&`, so the original text survives parsing.
+ * @param {*} value Value to be escaped.
+ * @return {string} The escaped value.
+ * @private
+ */
+const escapeAttributeValue = value => String(value)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/\u00A0/g, ' ');
+
+/**
+ * Escape the `<` of any `` element it is serialized into. A ` ` ${key}="${attributes[key]}"`).join('');
+ const attributesHtml = Object.keys(attributes)
+ .filter(key => validAttributeNameRegex.test(key))
+ .map(key => ` ${key}="${escapeAttributeValue(attributes[key])}"`).join('');
const nl = (__DEV__ && StyleSheet.debug) ? '\n' : '';
- return `${nl}`;
+ return `${nl}`;
}
/**
diff --git a/test/index.test.js b/test/index.test.js
index a90abe0..af4175c 100644
--- a/test/index.test.js
+++ b/test/index.test.js
@@ -78,6 +78,56 @@ describe('cssfun', () => {
expect(instance.el.id).to.be.equal('test');
});
+ it('must escape a style end tag in a declaration value', () => {
+ const instance = css({ root : { content : '""' } });
+ expect(instance.toString()).to.be.equal(
+ ``
+ );
+ });
+
+ it('must escape a style end tag in a selector', () => {
+ const instance = css({ '@global p' : { color : 'red' } });
+ expect(instance.toString()).to.not.include('
diff --git a/example/react/index.html b/example/react/index.html
index 73eeb93..b354762 100644
--- a/example/react/index.html
+++ b/example/react/index.html
@@ -12,7 +12,7 @@
"react-dom": "https://esm.sh/react-dom@19",
"react-dom/client": "https://esm.sh/react-dom@19/client",
"htm": "https://esm.sh/htm@3.1.0",
- "cssfun": "https://esm.run/cssfun@0.2.0-alpha.0"
+ "cssfun": "https://esm.run/cssfun@0.2.0-alpha.1"
}
}
diff --git a/example/vanilla/index.html b/example/vanilla/index.html
index db12c61..37ef9db 100644
--- a/example/vanilla/index.html
+++ b/example/vanilla/index.html
@@ -8,7 +8,7 @@
diff --git a/package-lock.json b/package-lock.json
index e017c97..a224d1b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "cssfun",
- "version": "0.2.0-alpha.0",
+ "version": "0.2.0-alpha.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cssfun",
- "version": "0.2.0-alpha.0",
+ "version": "0.2.0-alpha.1",
"license": "MIT",
"dependencies": {
"csstype": "3.2.3"
diff --git a/package.json b/package.json
index 95331f0..b545475 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cssfun",
- "version": "0.2.0-alpha.0",
+ "version": "0.2.0-alpha.1",
"description": "Near-zero runtime CSS-in-JS library",
"type": "module",
"main": "lib/index.cjs",