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 ` ``` +#### 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 + + ``` + + 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:** ``` Input styles object @@ -605,7 +692,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..367f7f5 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -2,13 +2,13 @@ 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.1/docs/api.md) ## 🔧 Core API ### 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,13 +36,13 @@ 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 **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.1/docs/api.md#css) - Creates and attaches a StyleSheet ### Style Object Structure @@ -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. @@ -416,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.1/docs/api.md#createtheme) - Creates a theme StyleSheet ## 🚀 Server-Side Rendering (SSR) @@ -480,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.1/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.1/docs/api.md#stylesheet_tocss) - Static method to render all instances as CSS ## 🛠️ Advanced Usage @@ -552,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.1/docs/api.md#stylesheet) - StyleSheet class documentation ## ⚠️ CSSFUN Best Practices @@ -644,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.1/docs/api.md) - **GitHub Repository**: [8tentaculos/cssfun](https://github.com/8tentaculos/cssfun) - **Examples**: Check the `example/` folder in the repository diff --git a/docs/api.md b/docs/api.md index c36ceba..fa2d8b6 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;`; 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 `${nl}`; + return `${nl}${escapeStyleText(this.render())}${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('