diff --git a/CHANGELOG.md b/CHANGELOG.md index fd03dc4..b9b31d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,51 @@ 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, 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. + + ```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. +- Fixed `toString()` emitting attribute names that are not legal HTML, which `setAttribute` already rejected on the DOM path. + +### Security + +- `StyleSheet.prototype.toString()` and `StyleSheet.toString()` now escape their output so it stays a single well formed ``. + + 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 ### Added diff --git a/README.md b/README.md index 2bde143..257460b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
+
--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;`; 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. |
@@ -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.
@@ -299,11 +303,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;`; 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**
@@ -321,3 +325,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/example/rasti/index.html b/example/rasti/index.html
index 27535aa..9f4f15e 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.1"
}
}
diff --git a/example/react/index.html b/example/react/index.html
index 659b2a5..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.1.0"
+ "cssfun": "https://esm.run/cssfun@0.2.0-alpha.1"
}
}
diff --git a/example/vanilla/index.html b/example/vanilla/index.html
index 648572d..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 3086b96..a224d1b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "cssfun",
- "version": "0.1.0",
+ "version": "0.2.0-alpha.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cssfun",
- "version": "0.1.0",
+ "version": "0.2.0-alpha.1",
"license": "MIT",
"dependencies": {
"csstype": "3.2.3"
diff --git a/package.json b/package.json
index 31cc57e..b545475 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cssfun",
- "version": "0.1.0",
+ "version": "0.2.0-alpha.1",
"description": "Near-zero runtime CSS-in-JS library",
"type": "module",
"main": "lib/index.cjs",
diff --git a/src/StyleSheet.js b/src/StyleSheet.js
index 7b06a44..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}`;
}
/**
@@ -392,6 +449,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..6c7ea5f 100644
--- a/src/css.js
+++ b/src/css.js
@@ -6,9 +6,11 @@ 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 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
- * 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 +25,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..831b3cf 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,10 @@ 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' } };
+// 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 322b6bf..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('