diff --git a/.changeset/empty-knives-walk.md b/.changeset/empty-knives-walk.md new file mode 100644 index 000000000..dbfa077b1 --- /dev/null +++ b/.changeset/empty-knives-walk.md @@ -0,0 +1,5 @@ +--- +'@fuzdev/fuz_css': minor +--- + +implement CSS literal classes diff --git a/.gitignore b/.gitignore index bdef460f4..5117c8d01 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,15 @@ # Deps -node_modules +node_modules/ # Output -/.svelte-kit -/build -/dist -/dist_* -/target -/.gro -/.zzz +.svelte-kit/ +build/ +dist/ +dist_*/ +target/ +.gro/ +.fuz/ +.zzz/ # Env .env* diff --git a/CLAUDE.md b/CLAUDE.md index 2086a1452..40e78b9d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,19 @@ # Fuz CSS framework and design system -CSS framework and design system built around **semantic styles** and **style variables** (design tokens as CSS custom properties). Early alpha with breaking changes ahead. +CSS framework and design system built on **semantic styles** and **style variables** (design tokens as CSS custom properties). Early alpha with breaking changes ahead. For code style, see the `fuz-stack` skill. For UI components (themes, color scheme controls), see `@fuzdev/fuz_ui`. +## Gro commands + +```bash +gro check # typecheck, test, lint, format check (run before committing) +gro typecheck # typecheck only (faster iteration) +gro test # run tests with vitest +gro gen # regenerate theme.css and other .gen files +gro build # build the package for production +``` + ## Design decisions ### Two core concepts @@ -26,7 +36,24 @@ For code style, see the `fuz-stack` skill. For UI components (themes, color sche ### Smart utility class generation -[gen_fuz_css.ts](src/lib/gen_fuz_css.ts) scans source files with regex extractors, collects class names, and outputs only CSS for classes actually used. Dynamic [interpreters](src/lib/css_class_interpreters.ts) handle pattern-based classes like `opacity_50`, `font_weight_700`, `z_index_100`. +[gen_fuz_css.ts](src/lib/gen_fuz_css.ts) scans source files with AST-based extraction ([css_class_extractor.ts](src/lib/css_class_extractor.ts)), collects class names, and outputs only CSS for classes actually used. Supports Svelte 5.16+ class syntax (`class={[...]}`, `class={{...}}`), clsx/cn calls, and `// @fuz-classes` comment hints. + +### Three class types + +- **Token classes** - Map to style variables: `p_md`, `color_a_5`, `gap_lg` +- **Composite classes** - Multi-property shortcuts: `box`, `row`, `ellipsis` +- **Literal classes** - CSS `property:value` syntax: `display:flex`, `opacity:50%` + +All class types support modifiers: responsive (`md:`), state (`hover:`), color-scheme (`dark:`), pseudo-element (`before:`). + +### CSS-literal syntax + +Literal classes use `property:value` syntax that maps 1:1 to CSS: +- `display:flex` → `display: flex;` +- `hover:opacity:80%` → `:hover { opacity: 80%; }` +- `md:dark:hover:opacity:80%` → nested media/ancestor/state wrappers + +Space encoding uses `~` for multi-value properties (`margin:0~auto`). Arbitrary breakpoints via `min-width(800px):` and `max-width(600px):`. ## Variable naming @@ -62,14 +89,22 @@ Import [style.css](src/lib/style.css) + [theme.css](src/lib/theme.css) for base - [theme.ts](src/lib/theme.ts) - Theme rendering, `ColorScheme` type, `render_theme_style()` - [themes.ts](src/lib/themes.ts) - Theme definitions (base, low/high contrast) +**CSS extraction:** + +- [css_class_extractor.ts](src/lib/css_class_extractor.ts) - AST-based class extraction from Svelte/TS files, `SourceLocation`, `ExtractionResult` + **CSS generation:** -- [gen_fuz_css.ts](src/lib/gen_fuz_css.ts) - Main generator API for Gro -- [css_classes.ts](src/lib/css_classes.ts) - All static class definitions (~1000+) +- [gen_fuz_css.ts](src/lib/gen_fuz_css.ts) - Main generator API for Gro, includes per-file caching with content hash validation +- [css_cache.ts](src/lib/css_cache.ts) - Cache infrastructure for incremental extraction (`.fuz/cache/css/`) +- [css_class_generation.ts](src/lib/css_class_generation.ts) - `CssClasses` collection, `generate_classes_css()`, `CssClassInterpreterContext`, CSS escaping +- [css_classes.ts](src/lib/css_classes.ts) - Token class definitions (spacing, sizing, colors, typography, borders, shadows) - [css_class_generators.ts](src/lib/css_class_generators.ts) - Class template generation functions - [css_class_composites.ts](src/lib/css_class_composites.ts) - Composite classes (`.box`, `.row`, `.column`, `.ellipsis`) -- [css_class_interpreters.ts](src/lib/css_class_interpreters.ts) - Dynamic interpreters for opacity, font-weight, z-index, border-radius -- [css_class_helpers.ts](src/lib/css_class_helpers.ts) - CSS class extraction, `CssClasses` collection, `generate_classes_css()` +- [css_class_interpreters.ts](src/lib/css_class_interpreters.ts) - Two interpreters: `modified_class_interpreter` (handles `hover:box`, `md:p_lg`) and `css_literal_interpreter` (handles `display:flex`) +- [css_literal.ts](src/lib/css_literal.ts) - CSS-literal parser, validator, `extract_and_validate_modifiers()` +- [css_ruleset_parser.ts](src/lib/css_ruleset_parser.ts) - CSS ruleset parsing via Svelte's parser, selector modification for modifiers +- [modifiers.ts](src/lib/modifiers.ts) - Declarative modifier definitions (breakpoints, states, pseudo-elements) **Stylesheets:** @@ -86,4 +121,8 @@ Import [style.css](src/lib/style.css) + [theme.css](src/lib/theme.css) for base ### Tests - [src/test/](src/test/) - [variables.test.ts](src/test/variables.test.ts) - Variable consistency (no duplicates, valid names) -- [css_class_helpers.test.ts](src/test/css_class_helpers.test.ts) - CSS extraction from Svelte/JS patterns +- [css_cache.test.ts](src/test/css_cache.test.ts) - Cache save/load, version invalidation, atomic writes +- [css_class_generation.test.ts](src/test/css_class_generation.test.ts) - CSS escaping, generation, interpreters, CssClasses +- [css_class_extractor.test.ts](src/test/css_class_extractor.test.ts) - AST extraction, location tracking +- [css_literal.test.ts](src/test/css_literal.test.ts) - CSS-literal parsing, validation, modifiers +- [css_ruleset_parser.test.ts](src/test/css_ruleset_parser.test.ts) - Ruleset parsing, selector modification diff --git a/README.md b/README.md index 743335684..41505717e 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,17 @@ [](https://css.fuz.dev/) -> CSS framework and design system 🌿 magical organic stylesheets +> CSS framework and design system 🌿 CSS with more utility Fuz CSS is a CSS framework and design system built around semantic styles and style variables. It's in early alpha with breaking changes ahead. -- view the [docs](https://css.fuz.dev/docs) at [css.fuz.dev](https://css.fuz.dev/) -- help with feedback and design in the [issues](https://github.com/fuzdev/fuz_css/issues) - and [discussions](https://github.com/fuzdev/fuz_css/discussions) -- more about the stack at [fuz.dev](https://www.fuz.dev/) +View the [docs](https://css.fuz.dev/docs) at [css.fuz.dev](https://css.fuz.dev/). +More about the stack at [fuz.dev](https://www.fuz.dev/) + +Interested in helping? We welcome feedback and design input in +the [issues](https://github.com/fuzdev/fuz_css/issues) +and [discussions](https://github.com/fuzdev/fuz_css/discussions). ## License [🐦](https://wikipedia.org/wiki/Free_and_open-source_software) diff --git a/package-lock.json b/package-lock.json index 4d9e22e8c..acf20dcaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,16 +10,19 @@ "license": "MIT", "devDependencies": { "@changesets/changelog-git": "^0.2.1", - "@fuzdev/fuz_code": "^0.38.0", + "@fuzdev/fuz_code": "^0.39.0", "@fuzdev/fuz_ui": "^0.177.0", - "@fuzdev/fuz_util": "^0.45.1", + "@fuzdev/fuz_util": "^0.45.3", "@ryanatkn/eslint-config": "^0.9.0", - "@ryanatkn/gro": "^0.184.0", + "@ryanatkn/gro": "^0.185.0", + "@sveltejs/acorn-typescript": "^1.0.8", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.49.1", "@sveltejs/package": "^2.5.7", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@types/node": "^24.10.1", + "@webref/css": "^8.1.3", + "acorn-jsx": "^5.3.2", "eslint": "^9.39.1", "eslint-plugin-svelte": "^3.13.1", "prettier": "^3.7.4", @@ -30,6 +33,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.48.1", "vitest": "^4.0.15", + "zimmerframe": "^1.1.4", "zod": "^4.1.13" }, "engines": { @@ -39,11 +43,27 @@ "url": "https://www.ryanatkn.com/funding" }, "peerDependencies": { - "@fuzdev/fuz_util": ">=0.42.0" + "@fuzdev/fuz_util": ">=0.42.0", + "@sveltejs/acorn-typescript": "^1", + "@webref/css": "^8", + "acorn-jsx": "^5", + "zimmerframe": "^1" }, "peerDependenciesMeta": { "@fuzdev/fuz_util": { "optional": true + }, + "@sveltejs/acorn-typescript": { + "optional": true + }, + "@webref/css": { + "optional": true + }, + "acorn-jsx": { + "optional": true + }, + "zimmerframe": { + "optional": true } } }, @@ -724,9 +744,9 @@ } }, "node_modules/@fuzdev/fuz_code": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@fuzdev/fuz_code/-/fuz_code-0.38.0.tgz", - "integrity": "sha512-11ow5NZbgdDnvNH46LDYaVdWjjo1yQSJRpkYr2OhZGcxc2FiZ/eU16T0xT6OhSJg+pRM9SRUPyNLI2k3jiW7Ig==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@fuzdev/fuz_code/-/fuz_code-0.39.0.tgz", + "integrity": "sha512-0YlInOGBNlnCAp3TKKv6gSjoyco3JAI9e/epplmnQMnAAHRTHvblJDWv8ePiFUJBQJJqGsJ2NldxswK9Vp7LXA==", "dev": true, "license": "MIT", "engines": { @@ -810,9 +830,9 @@ } }, "node_modules/@fuzdev/fuz_util": { - "version": "0.45.1", - "resolved": "https://registry.npmjs.org/@fuzdev/fuz_util/-/fuz_util-0.45.1.tgz", - "integrity": "sha512-szJ6FPXkeuNzoqxXwiC1q9xMiWZM37wiCyGdFLVVBq8FxwIpG1MAup/ZFyQC22QLg8sdybCbPadWiaYYUhhtUA==", + "version": "0.45.3", + "resolved": "https://registry.npmjs.org/@fuzdev/fuz_util/-/fuz_util-0.45.3.tgz", + "integrity": "sha512-N0xaUwFxGG1FuEkcVqB4t8Gqs2ReCVmmQf1kI7gErGuyKV9mRycHSFWFyT7/hQ9K4/0epsZj4cejj5fYIjkG/Q==", "dev": true, "license": "MIT", "engines": { @@ -1541,9 +1561,9 @@ } }, "node_modules/@ryanatkn/gro": { - "version": "0.184.0", - "resolved": "https://registry.npmjs.org/@ryanatkn/gro/-/gro-0.184.0.tgz", - "integrity": "sha512-J8Us3xspyjVBCuOG36+4X9qZiH5ydrjRRSN9Pfwk+xNgJsH5v11GXTl9PLkKvHJVHJ8qb4KFX/XA1k8bAmIM5g==", + "version": "0.185.0", + "resolved": "https://registry.npmjs.org/@ryanatkn/gro/-/gro-0.185.0.tgz", + "integrity": "sha512-Sr2Jrkz/RcyPP8N4BXzyRQhABgGAgsMJ0OpMpNVfN4WozKDbrN6YUZsHgZpF/byT5YSUz+6Hlhsx+4xrTMObKg==", "dev": true, "license": "MIT", "dependencies": { @@ -1552,8 +1572,8 @@ "esm-env": "^1.2.2", "mri": "^1.2.0", "oxc-parser": "^0.99.0", - "prettier": "^3.6.2", - "prettier-plugin-svelte": "^3.4.0", + "prettier": "^3.7.4", + "prettier-plugin-svelte": "^3.4.1", "ts-blank-space": "^0.6.2", "tslib": "^2.8.1", "zod": "^4.1.13" @@ -1625,9 +1645,9 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", - "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2203,6 +2223,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webref/css": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@webref/css/-/css-8.1.3.tgz", + "integrity": "sha512-lSniLpIAm3G5o0NJxUr1Ci0mXCxIwQ31cyvdU5L0jnWXb41I6Sg0KWqxZXRVh6Jgf03egoTCaqhAn4S3VRzCZQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "css-tree": "^3.1.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2429,6 +2459,21 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3142,6 +3187,14 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0", + "peer": true + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4718,9 +4771,9 @@ } }, "node_modules/zimmerframe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", - "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index f98ae64da..6dd6f1ab1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@fuzdev/fuz_css", "version": "0.43.0", "description": "CSS framework and design system", - "motto": "magical organic stylesheets", + "motto": "CSS with more utility", "glyph": "🌿", "logo": "logo.svg", "logo_alt": "a fuzzy tuft of green moss", @@ -30,25 +30,44 @@ "node": ">=22.15" }, "peerDependencies": { - "@fuzdev/fuz_util": ">=0.42.0" + "@fuzdev/fuz_util": ">=0.42.0", + "@sveltejs/acorn-typescript": "^1", + "@webref/css": "^8", + "acorn-jsx": "^5", + "zimmerframe": "^1" }, "peerDependenciesMeta": { "@fuzdev/fuz_util": { "optional": true + }, + "@sveltejs/acorn-typescript": { + "optional": true + }, + "@webref/css": { + "optional": true + }, + "acorn-jsx": { + "optional": true + }, + "zimmerframe": { + "optional": true } }, "devDependencies": { "@changesets/changelog-git": "^0.2.1", - "@fuzdev/fuz_code": "^0.38.0", + "@fuzdev/fuz_code": "^0.39.0", "@fuzdev/fuz_ui": "^0.177.0", - "@fuzdev/fuz_util": "^0.45.1", + "@fuzdev/fuz_util": "^0.45.3", "@ryanatkn/eslint-config": "^0.9.0", - "@ryanatkn/gro": "^0.184.0", + "@ryanatkn/gro": "^0.185.0", + "@sveltejs/acorn-typescript": "^1.0.8", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.49.1", "@sveltejs/package": "^2.5.7", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@types/node": "^24.10.1", + "@webref/css": "^8.1.3", + "acorn-jsx": "^5.3.2", "eslint": "^9.39.1", "eslint-plugin-svelte": "^3.13.1", "prettier": "^3.7.4", @@ -59,6 +78,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.48.1", "vitest": "^4.0.15", + "zimmerframe": "^1.1.4", "zod": "^4.1.13" }, "prettier": { diff --git a/src/lib/css_cache.ts b/src/lib/css_cache.ts new file mode 100644 index 000000000..e98cad97b --- /dev/null +++ b/src/lib/css_cache.ts @@ -0,0 +1,148 @@ +/** + * Cache infrastructure for incremental CSS class extraction. + * + * Provides per-file caching with content hash validation to avoid + * re-extracting classes from unchanged files. + * + * @module + */ + +import {mkdir, readFile, writeFile, unlink, rename} from 'node:fs/promises'; +import {dirname, join} from 'node:path'; + +import type {SourceLocation, ExtractionDiagnostic} from './diagnostics.js'; + +/** + * Cache version. Bump when any of these change: + * - `CachedExtraction` schema + * - `extract_css_classes_with_locations()` logic or output + * - `ExtractionDiagnostic` or `SourceLocation` structure + * + * v2: Use null instead of empty arrays for classes/diagnostics + */ +const CACHE_VERSION = 2; + +/** + * Cached extraction result for a single file. + * Uses `null` instead of empty arrays to avoid allocation overhead. + */ +export interface CachedExtraction { + /** Cache version - invalidates cache when bumped */ + v: number; + /** SHA-256 hash of the source file contents */ + content_hash: string; + /** Classes as [name, locations] tuples, or null if none */ + classes: Array<[string, Array]> | null; + /** Extraction diagnostics, or null if none */ + diagnostics: Array | null; +} + +/** + * Computes the cache file path for a source file. + * Cache structure mirrors source tree: `src/lib/Foo.svelte` → `.fuz/cache/css/src/lib/Foo.svelte.json` + * + * @param source_path - Absolute path to the source file + * @param cache_dir - Absolute path to the cache directory + * @param project_root - Normalized project root (must end with `/`) + */ +export const get_cache_path = ( + source_path: string, + cache_dir: string, + project_root: string, +): string => { + if (!source_path.startsWith(project_root)) { + throw new Error(`Source path "${source_path}" is not under project root "${project_root}"`); + } + const relative = source_path.slice(project_root.length); + return join(cache_dir, relative + '.json'); +}; + +/** + * Loads a cached extraction result from disk. + * Returns `null` if the cache is missing, corrupted, or has a version mismatch. + * This makes the cache self-healing: any error triggers re-extraction. + * + * @param cache_path - Absolute path to the cache file + */ +export const load_cached_extraction = async ( + cache_path: string, +): Promise => { + try { + const content = await readFile(cache_path, 'utf8'); + const cached = JSON.parse(content) as CachedExtraction; + + // Invalidate if version mismatch + if (cached.v !== CACHE_VERSION) { + return null; + } + + return cached; + } catch { + // Handles: file not found, invalid JSON, truncated file, permission errors + // All cases: return null to trigger re-extraction (self-healing) + return null; + } +}; + +/** + * Saves an extraction result to the cache. + * Uses atomic write (temp file + rename) for crash safety. + * Converts empty arrays to null to avoid allocation overhead on load. + * + * @param cache_path - Absolute path to the cache file + * @param content_hash - SHA-256 hash of the source file contents + * @param classes - Extracted classes with their locations, or null if none + * @param diagnostics - Extraction diagnostics, or null if none + */ +export const save_cached_extraction = async ( + cache_path: string, + content_hash: string, + classes: Map> | null, + diagnostics: Array | null, +): Promise => { + // Convert to null if empty to save allocation on load + const classes_array = classes && classes.size > 0 ? Array.from(classes.entries()) : null; + const diagnostics_array = diagnostics && diagnostics.length > 0 ? diagnostics : null; + + const data: CachedExtraction = { + v: CACHE_VERSION, + content_hash, + classes: classes_array, + diagnostics: diagnostics_array, + }; + + // Atomic write: temp file + rename + // Include pid + timestamp to avoid conflicts with concurrent writes + await mkdir(dirname(cache_path), {recursive: true}); + const temp_path = cache_path + '.tmp.' + process.pid + '.' + Date.now(); + await writeFile(temp_path, JSON.stringify(data)); + await rename(temp_path, cache_path); +}; + +/** + * Deletes a cached extraction file. + * Silently succeeds if the file doesn't exist. + * + * @param cache_path - Absolute path to the cache file + */ +export const delete_cached_extraction = async (cache_path: string): Promise => { + await unlink(cache_path).catch(() => { + // Ignore if already gone + }); +}; + +/** + * Converts a cached extraction back to the runtime format. + * Preserves null semantics (null = empty). + * + * @param cached - Cached extraction data + */ +export const from_cached_extraction = ( + cached: CachedExtraction, +): { + classes: Map> | null; + diagnostics: Array | null; +} => ({ + classes: cached.classes ? new Map(cached.classes) : null, + diagnostics: cached.diagnostics, +}); diff --git a/src/lib/css_class_composites.ts b/src/lib/css_class_composites.ts index e583b096a..892c794f7 100644 --- a/src/lib/css_class_composites.ts +++ b/src/lib/css_class_composites.ts @@ -1,6 +1,6 @@ -import type {CssClassDeclaration} from './css_class_helpers.js'; +import type {CssClassDefinition} from './css_class_generation.js'; -export const css_class_composites: Record = { +export const css_class_composites: Record = { pixelated: { declaration: ` image-rendering: -webkit-optimize-contrast; /* Safari */ @@ -9,207 +9,107 @@ export const css_class_composites: Record = { + // Composite classes go first, so they can be overridden by the more specific classes. + ...css_class_composites, + + /* + + typography + + */ + font_family_sans: {declaration: 'font-family: var(--font_family_sans);'}, + font_family_serif: {declaration: 'font-family: var(--font_family_serif);'}, + font_family_mono: {declaration: 'font-family: var(--font_family_mono);'}, + + ...generate_property_classes('line-height', ['0', '1', ...line_height_variants], (v) => + v === '0' || v === '1' ? v : `var(--line_height_${v})`, + ), + ...generate_property_classes( + 'font-size', + font_size_variants, + (v) => `var(--font_size_${v}); --font_size: var(--font_size_${v})`, + ), + ...generate_property_classes( + 'font-size', + icon_size_variants, + (v) => `var(--icon_size_${v}); --font_size: var(--icon_size_${v})`, + 'icon_size', + ), + + /* + + colors + + */ + ...generate_property_classes( + 'color', + text_color_variants.map(String), + (v) => `var(--text_color_${v})`, + 'text_color', + ), + ...generate_property_classes( + 'background-color', + [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), + (v) => `var(--darken_${v})`, + 'darken', + ), + ...generate_property_classes( + 'background-color', + [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), + (v) => `var(--lighten_${v})`, + 'lighten', + ), + bg: {declaration: 'background-color: var(--bg);'}, + fg: {declaration: 'background-color: var(--fg);'}, + ...generate_property_classes( + 'background-color', + [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), + (v) => `var(--bg_${v})`, + 'bg', + ), + ...generate_property_classes( + 'background-color', + [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), + (v) => `var(--fg_${v})`, + 'fg', + ), + ...generate_property_classes( + 'color', + [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), + (v) => `var(--darken_${v})`, + 'color_darken', + ), + ...generate_property_classes( + 'color', + [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), + (v) => `var(--lighten_${v})`, + 'color_lighten', + ), + color_bg: {declaration: 'color: var(--bg);'}, + color_fg: {declaration: 'color: var(--fg);'}, + ...generate_property_classes( + 'color', + [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), + (v) => `var(--bg_${v})`, + 'color_bg', + ), + ...generate_property_classes( + 'color', + [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), + (v) => `var(--fg_${v})`, + 'color_fg', + ), + ...generate_classes( + (hue: string) => ({ + name: `hue_${hue}`, + css: `--hue: var(--hue_${hue});`, + }), + color_variants, + ), + ...generate_classes( + (hue: string, intensity: string) => ({ + name: `color_${hue}_${intensity}`, + css: `color: var(--color_${hue}_${intensity});`, + }), + color_variants, + COLOR_INTENSITIES, + ), + ...generate_classes( + (hue: string, intensity: string) => ({ + name: `bg_${hue}_${intensity}`, + css: `background-color: var(--color_${hue}_${intensity});`, + }), + color_variants, + COLOR_INTENSITIES, + ), + + /* + + borders + + */ + ...generate_property_classes( + 'border-color', + [1, 2, 3, 4, 5].map(String), + (v) => `var(--border_color_${v})`, + ), + ...generate_property_classes('border-color', color_variants, (v) => `var(--border_color_${v})`), + ...generate_property_classes( + 'outline-color', + [1, 2, 3, 4, 5].map(String), + (v) => `var(--border_color_${v})`, + ), + ...generate_property_classes('outline-color', color_variants, (v) => `var(--border_color_${v})`), + + ...generate_property_classes('border-width', ['0', ...border_width_variants.map(String)], (v) => + v === '0' ? '0' : `var(--border_width_${v})`, + ), + ...generate_property_classes('outline-width', ['0', ...border_width_variants.map(String)], (v) => + v === '0' ? '0' : `var(--border_width_${v})`, + ), + outline_width_focus: {declaration: 'outline-width: var(--outline_width_focus);'}, + outline_width_active: {declaration: 'outline-width: var(--outline_width_active);'}, + + ...generate_property_classes( + 'border-radius', + border_radius_variants, + (v) => `var(--border_radius_${v})`, + ), + ...generate_border_radius_corners(border_radius_variants, (v) => `var(--border_radius_${v})`), + + /* + + shadows + + */ + ...generate_shadow_classes(['xs', 'sm', 'md', 'lg', 'xl'], { + xs: '1', + sm: '2', + md: '3', + lg: '4', + xl: '5', + }), + ...generate_classes( + (value: string) => ({ + name: `shadow_color_${value}`, + css: `--shadow_color: var(--shadow_color_${value});`, + }), + shadow_semantic_values, + ), + ...generate_classes( + (hue: string) => ({ + name: `shadow_color_${hue}`, + css: `--shadow_color: var(--shadow_color_${hue});`, + }), + color_variants, + ), + ...generate_classes( + (alpha: number) => ({ + name: `shadow_alpha_${alpha}`, + css: `--shadow_alpha: var(--shadow_alpha_${alpha});`, + }), + shadow_alpha_variants, + ), + + /* + + layout + + */ + ...generate_property_classes( + 'width', + [ + '0', + '100', + '1px', + '2px', + '3px', + 'auto', + 'max-content', + 'min-content', + 'fit-content', + 'stretch', + ...space_variants, + ], + format_dimension_value, + ), + ...generate_property_classes( + 'height', + [ + '0', + '100', + '1px', + '2px', + '3px', + 'auto', + 'max-content', + 'min-content', + 'fit-content', + 'stretch', + ...space_variants, + ], + format_dimension_value, + ), + + ...generate_property_classes( + 'top', + ['0', '100', '1px', '2px', '3px', 'auto', ...space_variants], + format_spacing_value, + ), + ...generate_property_classes( + 'right', + ['0', '100', '1px', '2px', '3px', 'auto', ...space_variants], + format_spacing_value, + ), + ...generate_property_classes( + 'bottom', + ['0', '100', '1px', '2px', '3px', 'auto', ...space_variants], + format_spacing_value, + ), + ...generate_property_classes( + 'left', + ['0', '100', '1px', '2px', '3px', 'auto', ...space_variants], + format_spacing_value, + ), + + ...generate_property_classes( + 'inset', + ['0', '100', '1px', '2px', '3px', 'auto', ...space_variants], + format_spacing_value, + ), + + ...generate_directional_classes( + 'padding', + ['0', '100', '1px', '2px', '3px', ...space_variants], + format_spacing_value, + ), + ...generate_directional_classes( + 'margin', + ['0', '100', '1px', '2px', '3px', 'auto', ...space_variants], + format_spacing_value, + ), + ...generate_property_classes('gap', space_variants, format_spacing_value), + ...generate_property_classes('column-gap', space_variants, format_spacing_value), + ...generate_property_classes('row-gap', space_variants, format_spacing_value), +}; diff --git a/src/lib/css_class_extractor.ts b/src/lib/css_class_extractor.ts new file mode 100644 index 000000000..2a8cd7c91 --- /dev/null +++ b/src/lib/css_class_extractor.ts @@ -0,0 +1,1018 @@ +/** + * AST-based CSS class extraction for Svelte and TypeScript files. + * + * Replaces regex-based extraction with proper parsing to handle: + * - `class="display:flex"` - string attributes + * - `class={{ active, disabled: !enabled }}` - object attributes (Svelte 5.16+) + * - `class={[cond && 'box', 'display:flex']}` - array attributes (Svelte 5.16+) + * - `class:active={cond}` - class directives + * - `clsx('foo', { bar: true })` - class utility function calls + * - Variables with class-related names + * - `// @fuz-classes class1 class2` - comment hints for dynamic classes + * + * @module + */ + +import {parse as parse_svelte, type AST} from 'svelte/compiler'; +import {walk, type Visitors} from 'zimmerframe'; +import {Parser, type Node} from 'acorn'; +import {tsPlugin} from '@sveltejs/acorn-typescript'; + +import {type SourceLocation, type ExtractionDiagnostic} from './diagnostics.js'; + +// +// Types +// + +/** + * Extraction result with classes mapped to their source locations. + * Uses `null` instead of empty collections to avoid allocation overhead. + * + * Uses embedded diagnostics (rather than a Result type) because file extraction + * can partially succeed: some classes may be extracted while others produce errors. + * This differs from {@link CssLiteralParseResult} which uses a discriminated union + * because single-class parsing is binary success/failure. + */ +export interface ExtractionResult { + /** + * Map from class name to locations where it was used, or null if none. + * Keys = unique classes, values = locations for diagnostics/IDE integration. + */ + classes: Map> | null; + /** Variables that were used in class contexts, or null if none */ + tracked_vars: Set | null; + /** Diagnostics from the extraction phase, or null if none */ + diagnostics: Array | null; +} + +/** + * Acorn plugin type - a function that extends the Parser class. + */ +export type AcornPlugin = (BaseParser: typeof Parser) => typeof Parser; + +/** + * Options for CSS class extraction. + */ +export interface ExtractCssClassesOptions { + /** + * File path used to determine extraction method (Svelte vs TS) + * and for location tracking in diagnostics. + */ + filename?: string; + /** + * Additional acorn plugins to use when parsing TS/JS files. + * Useful for adding JSX support via `acorn-jsx` for React projects. + * + * @example + * ```ts + * import jsx from 'acorn-jsx'; + * extract_css_classes(source, { acorn_plugins: [jsx()] }); + * ``` + */ + acorn_plugins?: Array; +} + +// +// Utilities +// + +/** + * Helper class for converting character offsets to line/column positions. + * Svelte template nodes (Comment, Text, ExpressionTag) only have char offsets, + * so this class enables efficient conversion. + * + * Build: O(n) where n = source length + * Lookup: O(log m) where m = number of lines (binary search) + */ +export class SourceIndex { + private line_starts: Array; + + constructor(source: string) { + this.line_starts = [0]; + for (let i = 0; i < source.length; i++) { + if (source[i] === '\n') this.line_starts.push(i + 1); + } + } + + /** + * Converts a character offset to a source location. + * + * @param offset - 0-based character offset in the source + * @param file - File path for the location + * @returns SourceLocation with 1-based line and column + */ + get_location(offset: number, file: string): SourceLocation { + // Binary search for line + let low = 0; + let high = this.line_starts.length - 1; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + if (this.line_starts[mid]! <= offset) low = mid; + else high = mid - 1; + } + return {file, line: low + 1, column: offset - this.line_starts[low]! + 1}; + } +} + +/** + * Adds a class with its location to the extraction result. + * + * @param classes - Map of classes to locations + * @param class_name - Class name to add + * @param location - Source location where the class was found + */ +const add_class_with_location = ( + classes: Map>, + class_name: string, + location: SourceLocation, +): void => { + const existing = classes.get(class_name); + if (existing) { + existing.push(location); + } else { + classes.set(class_name, [location]); + } +}; + +// Known class utility function names +const CLASS_UTILITY_FUNCTIONS = new Set([ + 'clsx', // clsx package + 'cn', // common alias (shadcn/ui convention) + 'classNames', // classnames package + 'classnames', // lowercase variant + 'cx', // emotion and other libs +]); + +// Svelte 5 runes that wrap expressions we should extract from +const SVELTE_RUNES = new Set(['$derived', '$state']); + +// Pattern for variables with class-related names +const CLASS_NAME_PATTERN = /(class|classes|class_?names?)$/i; + +/** + * State maintained during AST walking. + */ +interface WalkState { + /** Map from class name to locations */ + classes: Map>; + /** Variables used in class contexts */ + tracked_vars: Set; + /** Variables with class-like names, mapped to their initializers */ + class_name_vars: Map; + /** Whether we're in a class context (for tracking variable usage) */ + in_class_context: boolean; + /** File path for creating locations */ + file: string; + /** Source index for char offset → line/column conversion (template only) */ + source_index: SourceIndex | null; + /** Diagnostics collected during extraction */ + diagnostics: Array; +} + +/** + * Adds a class to the state with a location. + */ +const add_class = (state: WalkState, class_name: string, location: SourceLocation): void => { + add_class_with_location(state.classes, class_name, location); +}; + +/** + * Creates a location from a character offset using the source index. + */ +const location_from_offset = (state: WalkState, offset: number): SourceLocation => { + if (state.source_index) { + return state.source_index.get_location(offset, state.file); + } + // Fallback for script context (should have line/column from AST) + return {file: state.file, line: 1, column: 1}; +}; + +/** + * Extracts CSS classes from a Svelte file using AST parsing. + * + * @param source - The Svelte file source code + * @param file - File path for location tracking + * @returns Extraction result with classes, tracked variables, and diagnostics + */ +export const extract_from_svelte = (source: string, file = ''): ExtractionResult => { + const classes: Map> = new Map(); + const tracked_vars: Set = new Set(); + const class_name_vars: Map = new Map(); + const diagnostics: Array = []; + const source_index = new SourceIndex(source); + + let ast: AST.Root; + try { + ast = parse_svelte(source, {modern: true}); + } catch (err) { + // Emit diagnostic about parse failure + diagnostics.push({ + phase: 'extraction', + level: 'warning', + message: `Failed to parse Svelte file: ${err instanceof Error ? err.message : 'unknown error'}`, + suggestion: 'Check for syntax errors in the file', + location: {file, line: 1, column: 1}, + }); + // Return with diagnostics (classes/tracked_vars empty, so null) + return {classes: null, tracked_vars: null, diagnostics}; + } + + const state: WalkState = { + classes, + tracked_vars, + class_name_vars, + in_class_context: false, + file, + source_index, + diagnostics, + }; + + // Extract from @fuz-classes comments via AST (Svelte Comment nodes) + extract_fuz_classes_from_svelte_comments(ast, state); + + // Walk the template AST + walk_template(ast.fragment, state); + + // Walk the script AST (module and instance) including @fuz-classes comments + if (ast.instance) { + extract_fuz_classes_from_script(ast.instance, source, state); + walk_script(ast.instance.content, state); + } + if (ast.module) { + extract_fuz_classes_from_script(ast.module, source, state); + walk_script(ast.module.content, state); + } + + // Second pass: extract from tracked variables that weren't already processed + if (tracked_vars.size > 0 && (ast.instance || ast.module)) { + extract_from_tracked_vars(ast, state); + } + + // Convert empty to null + return { + classes: classes.size > 0 ? classes : null, + tracked_vars: tracked_vars.size > 0 ? tracked_vars : null, + diagnostics: diagnostics.length > 0 ? diagnostics : null, + }; +}; + +/** + * Parses @fuz-classes content from a comment, handling the colon variant. + * Returns the list of class names or null if not a @fuz-classes comment. + */ +const FUZ_CLASSES_PATTERN = /^\s*@fuz-classes(:?)\s+(.+?)\s*$/; + +const parse_fuz_classes_comment = ( + content: string, + location: SourceLocation, + diagnostics: Array, +): Array | null => { + // Match @fuz-classes with optional colon + const match = FUZ_CLASSES_PATTERN.exec(content); + if (!match) return null; + + const has_colon = match[1] === ':'; + const class_list = match[2]!; + + // Emit warning if colon was used + if (has_colon) { + diagnostics.push({ + phase: 'extraction', + level: 'warning', + message: '@fuz-classes: (with colon) is deprecated, use @fuz-classes (without colon)', + suggestion: 'Remove the colon after @fuz-classes', + location, + }); + } + + return class_list.split(/\s+/).filter(Boolean); +}; + +/** + * Extracts @fuz-classes from Svelte HTML Comment nodes. + */ +const extract_fuz_classes_from_svelte_comments = (ast: AST.Root, state: WalkState): void => { + // Walk the fragment looking for Comment nodes + const visitors: Visitors = { + Comment(node, {state}) { + const location = location_from_offset(state, node.start); + const classes = parse_fuz_classes_comment(node.data, location, state.diagnostics); + if (classes) { + for (const cls of classes) { + add_class(state, cls, location); + } + } + }, + }; + + walk(ast.fragment as AST.SvelteNode, state, visitors); +}; + +/** + * Extracts @fuz-classes from script blocks by re-parsing with acorn. + * Svelte's parser doesn't expose JS comments, so we parse the + * script source separately to get comments via acorn's onComment callback. + */ +const extract_fuz_classes_from_script = ( + script: AST.Script, + source: string, + state: WalkState, +): void => { + // Extract the script source using start/end positions + // Svelte AST nodes have start/end but the TypeScript types don't include them + const content = script.content as unknown as {start: number; end: number}; + const script_source = source.slice(content.start, content.end); + + // Calculate the line offset for proper location reporting + // Count newlines before the script content starts + let line_offset = 0; + for (let i = 0; i < content.start; i++) { + if (source[i] === '\n') line_offset++; + } + + // Collect comments via acorn's onComment callback + const comments: Array<{value: string; loc: {start: {line: number; column: number}}}> = []; + + try { + const parser = Parser.extend(tsPlugin()); + parser.parse(script_source, { + ecmaVersion: 'latest', + sourceType: 'module', + locations: true, + onComment: ( + _block: boolean, + text: string, + _start: number, + _end: number, + startLoc?: {line: number; column: number}, + ) => { + if (startLoc) { + comments.push({value: text, loc: {start: startLoc}}); + } + }, + }); + } catch { + // If parsing fails, we can't extract comments + return; + } + + // Process @fuz-classes comments + for (const comment of comments) { + const location: SourceLocation = { + file: state.file, + line: comment.loc.start.line + line_offset, + column: comment.loc.start.column + 1, + }; + const class_list = parse_fuz_classes_comment(comment.value, location, state.diagnostics); + if (class_list) { + for (const cls of class_list) { + add_class(state, cls, location); + } + } + } +}; + +/** + * Extracts CSS classes from a TypeScript/JS file using AST parsing. + * + * @param source - The TS/JS file source code + * @param file - File path for location tracking + * @param acorn_plugins - Additional acorn plugins (e.g., acorn-jsx for React) + * @returns Extraction result with classes, tracked variables, and diagnostics + */ +export const extract_from_ts = ( + source: string, + file = '', + acorn_plugins?: Array, +): ExtractionResult => { + const classes: Map> = new Map(); + const tracked_vars: Set = new Set(); + const class_name_vars: Map = new Map(); + const diagnostics: Array = []; + + // Collect comments via acorn's onComment callback + const comments: Array<{value: string; loc: {start: {line: number; column: number}}}> = []; + + let ast: Node; + try { + // Use acorn with TypeScript plugin, plus any additional plugins (e.g., jsx) + const plugins: Array = [tsPlugin(), ...(acorn_plugins ?? [])]; + const parser = plugins.reduce((p, plugin) => plugin(p), Parser); + ast = parser.parse(source, { + ecmaVersion: 'latest', + sourceType: 'module', + locations: true, + onComment: ( + _block: boolean, + text: string, + _start: number, + _end: number, + startLoc?: {line: number; column: number}, + ) => { + if (startLoc) { + comments.push({value: text, loc: {start: startLoc}}); + } + }, + }); + } catch (err) { + // Emit diagnostic about parse failure + diagnostics.push({ + phase: 'extraction', + level: 'warning', + message: `Failed to parse TypeScript/JS file: ${err instanceof Error ? err.message : 'unknown error'}`, + suggestion: 'Check for syntax errors in the file', + location: {file, line: 1, column: 1}, + }); + // Return with diagnostics (classes/tracked_vars empty, so null) + return {classes: null, tracked_vars: null, diagnostics}; + } + + // Process @fuz-classes comments + for (const comment of comments) { + const location: SourceLocation = { + file, + line: comment.loc.start.line, + column: comment.loc.start.column + 1, + }; + const class_list = parse_fuz_classes_comment(comment.value, location, diagnostics); + if (class_list) { + for (const cls of class_list) { + add_class_with_location(classes, cls, location); + } + } + } + + const state: WalkState = { + classes, + tracked_vars, + class_name_vars, + in_class_context: false, + file, + source_index: null, // Not needed for TS files - acorn provides locations + diagnostics, + }; + + walk_script(ast, state); + + // Second pass: extract from tracked variables that weren't already processed + // This handles JSX patterns where className={foo} is encountered after const foo = '...' + if (tracked_vars.size > 0) { + extract_from_tracked_vars_in_script(ast, state); + } + + // Convert empty to null + return { + classes: classes.size > 0 ? classes : null, + tracked_vars: tracked_vars.size > 0 ? tracked_vars : null, + diagnostics: diagnostics.length > 0 ? diagnostics : null, + }; +}; + +/** + * Unified extraction function that auto-detects file type. + * Returns just the class names as a Set. + * + * @param source - The file source code + * @param options - Extraction options + * @returns Set of class names + */ +export const extract_css_classes = ( + source: string, + options: ExtractCssClassesOptions = {}, +): Set => { + const result = extract_css_classes_with_locations(source, options); + return result.classes ? new Set(result.classes.keys()) : new Set(); +}; + +/** + * Unified extraction function that auto-detects file type. + * Returns full extraction result with locations and diagnostics. + * + * @param source - The file source code + * @param options - Extraction options + * @returns Full extraction result with classes, tracked variables, and diagnostics + */ +export const extract_css_classes_with_locations = ( + source: string, + options: ExtractCssClassesOptions = {}, +): ExtractionResult => { + const {filename, acorn_plugins} = options; + const ext = filename ? filename.slice(filename.lastIndexOf('.')) : ''; + const file = filename ?? ''; + + if (ext === '.svelte' || ext === '.html') { + return extract_from_svelte(source, file); + } else if (ext === '.ts' || ext === '.js' || ext === '.tsx' || ext === '.jsx') { + return extract_from_ts(source, file, acorn_plugins); + } + + // Default to Svelte-style extraction (handles both) + const svelte_result = extract_from_svelte(source, file); + if (svelte_result.classes) { + return svelte_result; + } + return extract_from_ts(source, file, acorn_plugins); +}; + +// Template AST walking + +/** + * Walks the Svelte template AST to extract class names. + */ +const walk_template = (fragment: AST.Fragment, state: WalkState): void => { + const visitors: Visitors = { + // Handle regular elements and components + RegularElement(node, {state, next}) { + process_element_attributes(node.attributes, state); + next(); + }, + SvelteElement(node, {state, next}) { + process_element_attributes(node.attributes, state); + next(); + }, + SvelteComponent(node, {state, next}) { + process_element_attributes(node.attributes, state); + next(); + }, + Component(node, {state, next}) { + process_element_attributes(node.attributes, state); + next(); + }, + SvelteFragment(node, {state, next}) { + process_element_attributes(node.attributes, state); + next(); + }, + }; + + walk(fragment as AST.SvelteNode, state, visitors); +}; + +/** + * Processes attributes on an element to extract class names. + */ +const process_element_attributes = ( + attributes: Array, + state: WalkState, +): void => { + for (const attr of attributes) { + // Handle class attribute + if (attr.type === 'Attribute' && attr.name === 'class') { + extract_from_attribute_value(attr.value, state); + } + + // Handle class: directive + if (attr.type === 'ClassDirective') { + add_class(state, attr.name, location_from_offset(state, attr.start)); + } + } +}; + +/** + * Extracts classes from an attribute value. + * Handles string literals, expressions, objects, and arrays. + */ +const extract_from_attribute_value = (value: AST.Attribute['value'], state: WalkState): void => { + if (value === true) { + // Boolean attribute, no classes + return; + } + + // Handle array of Text and ExpressionTag (e.g., class="foo {expr} bar") + if (Array.isArray(value)) { + for (const part of value) { + if (part.type === 'Text') { + // Static text: split on whitespace + const location = location_from_offset(state, part.start); + for (const cls of part.data.split(/\s+/).filter(Boolean)) { + add_class(state, cls, location); + } + } else { + // ExpressionTag: extract from the expression + state.in_class_context = true; + extract_from_expression(part.expression, state); + state.in_class_context = false; + } + } + return; + } + + // Handle single ExpressionTag (e.g., class={expression}) + // At this point, value is an ExpressionTag + state.in_class_context = true; + extract_from_expression(value.expression, state); + state.in_class_context = false; +}; + +/** + * Extracts classes from a TS expression. + * Handles strings, arrays, objects, conditionals, and function calls. + */ +const extract_from_expression = (expr: AST.SvelteNode, state: WalkState): void => { + // Get location from the expression's start offset + const get_location = (): SourceLocation => { + const node = expr as unknown as {start?: number; loc?: {start: {line: number; column: number}}}; + if (node.loc) { + return {file: state.file, line: node.loc.start.line, column: node.loc.start.column + 1}; + } + if (node.start !== undefined) { + return location_from_offset(state, node.start); + } + return {file: state.file, line: 1, column: 1}; + }; + + switch (expr.type) { + case 'Literal': { + // String literal + const node = expr as unknown as {value: unknown}; + if (typeof node.value === 'string') { + const location = get_location(); + for (const cls of node.value.split(/\s+/).filter(Boolean)) { + add_class(state, cls, location); + } + } + break; + } + + case 'TemplateLiteral': { + // Template literal - extract static parts that are complete tokens + // Only extract tokens that are whitespace-bounded (not fragments like `icon-` from `icon-${size}`) + const node = expr as unknown as { + quasis: Array<{ + value: {raw: string}; + start?: number; + loc?: {start: {line: number; column: number}}; + }>; + expressions: Array; + }; + + const has_expressions = node.expressions.length > 0; + + for (let i = 0; i < node.quasis.length; i++) { + const quasi = node.quasis[i]!; + if (!quasi.value.raw) continue; + + const location = quasi.loc + ? {file: state.file, line: quasi.loc.start.line, column: quasi.loc.start.column + 1} + : quasi.start !== undefined + ? location_from_offset(state, quasi.start) + : get_location(); + + const raw = quasi.value.raw; + + if (!has_expressions) { + // No expressions - extract all tokens (pure static template literal) + for (const cls of raw.split(/\s+/).filter(Boolean)) { + add_class(state, cls, location); + } + } else { + // Has expressions - only extract complete tokens + // A token is complete if bounded by whitespace/string-boundary on both sides + const is_first = i === 0; + const is_last = i === node.quasis.length - 1; + const has_expr_before = !is_first; + const has_expr_after = !is_last; + + // Split preserving info about boundaries + const tokens = raw.split(/\s+/); + const starts_with_ws = /^\s/.test(raw); + const ends_with_ws = /\s$/.test(raw); + + for (let j = 0; j < tokens.length; j++) { + const token = tokens[j]; + if (!token) continue; + + const is_first_token = j === 0; + const is_last_token = j === tokens.length - 1; + + // Token is bounded on the left if: + // - It's the first token AND (is_first quasi OR starts_with_ws) + // - OR it's not the first token (preceded by whitespace from split) + const bounded_left = !is_first_token || is_first || (has_expr_before && starts_with_ws); + + // Token is bounded on the right if: + // - It's the last token AND (is_last quasi OR ends_with_ws) + // - OR it's not the last token (followed by whitespace from split) + const bounded_right = !is_last_token || is_last || (has_expr_after && ends_with_ws); + + if (bounded_left && bounded_right) { + add_class(state, token, location); + } + } + } + } + + // Also extract from expressions (e.g., ternaries inside the template) + for (const subexpr of node.expressions) { + extract_from_expression(subexpr as AST.SvelteNode, state); + } + break; + } + + case 'ArrayExpression': { + // Array: extract from each element + const node = expr as unknown as {elements: Array}; + for (const element of node.elements) { + if (element) { + extract_from_expression(element as AST.SvelteNode, state); + } + } + break; + } + + case 'ObjectExpression': { + // Object: extract keys (values are conditions) + const node = expr as unknown as { + properties: Array<{ + type: string; + key: unknown; + computed: boolean; + start?: number; + loc?: {start: {line: number; column: number}}; + }>; + }; + for (const prop of node.properties) { + if (prop.type === 'Property' && !prop.computed) { + // Non-computed key - extract the key as a class name + const key = prop.key as {type: string; name?: string; value?: string}; + const location = prop.loc + ? {file: state.file, line: prop.loc.start.line, column: prop.loc.start.column + 1} + : prop.start !== undefined + ? location_from_offset(state, prop.start) + : get_location(); + if (key.type === 'Identifier') { + add_class(state, key.name!, location); + } else if (key.type === 'Literal' && typeof key.value === 'string') { + // Handle string keys like { 'display:flex': condition } + for (const cls of key.value.split(/\s+/).filter(Boolean)) { + add_class(state, cls, location); + } + } + } + } + break; + } + + case 'ConditionalExpression': { + // Ternary: extract from both branches + const node = expr as unknown as {consequent: unknown; alternate: unknown}; + extract_from_expression(node.consequent as AST.SvelteNode, state); + extract_from_expression(node.alternate as AST.SvelteNode, state); + break; + } + + case 'LogicalExpression': { + // && or ||: extract from both sides + const node = expr as unknown as {left: unknown; right: unknown}; + extract_from_expression(node.left as AST.SvelteNode, state); + extract_from_expression(node.right as AST.SvelteNode, state); + break; + } + + case 'CallExpression': { + // Function call: check if it's a class utility function or Svelte rune + const node = expr as unknown as { + callee: {type: string; name?: string; object?: {name?: string}; property?: {name?: string}}; + arguments: Array; + }; + + let should_extract = false; + + if (node.callee.type === 'Identifier') { + // Direct call: clsx(), cn(), $derived(), etc. + should_extract = + CLASS_UTILITY_FUNCTIONS.has(node.callee.name!) || SVELTE_RUNES.has(node.callee.name!); + } else if (node.callee.type === 'MemberExpression') { + // Member call: $derived.by(), etc. + const obj = node.callee.object; + if (obj?.name && SVELTE_RUNES.has(obj.name)) { + should_extract = true; + } + } + + if (should_extract) { + for (const arg of node.arguments) { + extract_from_expression(arg as AST.SvelteNode, state); + } + } + break; + } + + case 'Identifier': { + // Variable reference: track it for later extraction + const node = expr as unknown as {name: string}; + if (state.in_class_context) { + state.tracked_vars.add(node.name); + } + break; + } + + case 'MemberExpression': { + // Member access like obj.prop - we can't statically extract + // but track the root identifier + break; + } + + case 'TaggedTemplateExpression': { + // Tagged template literal like css`class-name` - extract from the template + const node = expr as unknown as {quasi: unknown}; + if (node.quasi) { + extract_from_expression(node.quasi as AST.SvelteNode, state); + } + break; + } + + case 'ArrowFunctionExpression': + case 'FunctionExpression': { + // Function expression: extract from the body + // Handles $derived.by(() => cond ? 'a' : 'b') + const node = expr as unknown as {body: unknown}; + if (node.body) { + extract_from_expression(node.body as AST.SvelteNode, state); + } + break; + } + + case 'BlockStatement': { + // Block statement: walk all statements looking for return statements + const node = expr as unknown as {body: Array}; + for (const stmt of node.body) { + extract_from_expression(stmt as AST.SvelteNode, state); + } + break; + } + + case 'ReturnStatement': { + // Return statement: extract from the argument + const node = expr as unknown as {argument: unknown}; + if (node.argument) { + extract_from_expression(node.argument as AST.SvelteNode, state); + } + break; + } + + default: + // Other expression types we don't handle + break; + } +}; + +// Script AST walking + +/** + * Extracts classes from a JSX attribute value (React className, Preact/Solid class, Solid classList). + * Handles string literals and expression containers. + * Sets in_class_context to enable variable tracking. + */ +const extract_from_jsx_attribute_value = (value: unknown, state: WalkState): void => { + const node = value as { + type: string; + value?: string; + expression?: unknown; + loc?: {start: {line: number; column: number}}; + }; + + if (node.type === 'Literal' && typeof node.value === 'string') { + // Static className="foo bar" + const location: SourceLocation = node.loc + ? {file: state.file, line: node.loc.start.line, column: node.loc.start.column + 1} + : {file: state.file, line: 1, column: 1}; + for (const cls of node.value.split(/\s+/).filter(Boolean)) { + add_class(state, cls, location); + } + } else if (node.type === 'JSXExpressionContainer' && node.expression) { + // Dynamic className={expr} - enable variable tracking + const prev_context = state.in_class_context; + state.in_class_context = true; + extract_from_expression(node.expression as AST.SvelteNode, state); + state.in_class_context = prev_context; + } +}; + +/** + * Walks a TypeScript/JS AST to extract class names. + */ +const walk_script = (ast: unknown, state: WalkState): void => { + const visitors: Visitors = { + // Variable declarations + VariableDeclarator(node, {state, next}) { + const declarator = node as unknown as {id: {type: string; name: string}; init: unknown}; + if (declarator.id.type === 'Identifier') { + const name = declarator.id.name; + // Check if variable name matches class pattern + if (CLASS_NAME_PATTERN.test(name)) { + state.class_name_vars.set(name, declarator.init); + if (declarator.init) { + extract_from_expression(declarator.init as AST.SvelteNode, state); + } + } + // Also check if this variable was tracked from usage + if (state.tracked_vars.has(name) && declarator.init) { + extract_from_expression(declarator.init as AST.SvelteNode, state); + } + } + next(); + }, + + // Call expressions (for clsx/cn calls outside of class attributes) + CallExpression(node, {state, next}) { + const call = node as unknown as { + callee: {type: string; name?: string}; + arguments: Array; + }; + if (call.callee.type === 'Identifier' && CLASS_UTILITY_FUNCTIONS.has(call.callee.name!)) { + for (const arg of call.arguments) { + extract_from_expression(arg as AST.SvelteNode, state); + } + } + next(); + }, + + // Object properties with class-related keys + Property(node, {state, next}) { + const prop = node as unknown as { + key: {type: string; name?: string; value?: string}; + value: unknown; + computed: boolean; + }; + if (!prop.computed) { + // Extract key name from identifier or string literal + let key_name: string | undefined; + if (prop.key.type === 'Identifier') { + key_name = prop.key.name; + } else if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') { + key_name = prop.key.value; + } + if ( + key_name && + (key_name === 'class' || key_name === 'className' || CLASS_NAME_PATTERN.test(key_name)) + ) { + extract_from_expression(prop.value as AST.SvelteNode, state); + } + } + next(); + }, + + // JSX elements (React/Preact/Solid) - extract class-related attributes + // These are only present when acorn-jsx plugin is used + JSXElement(node, {state, next}) { + const element = node as unknown as { + openingElement: { + attributes: Array<{ + type: string; + name?: {type: string; name?: string}; + value?: unknown; + }>; + }; + }; + for (const attr of element.openingElement.attributes) { + if (attr.type === 'JSXAttribute' && attr.value) { + const attr_name = attr.name?.name; + // className (React), class (Preact/Solid) + if (attr_name === 'className' || attr_name === 'class') { + extract_from_jsx_attribute_value(attr.value, state); + } + // classList (Solid) - object syntax like classList={{ active: isActive }} + else if (attr_name === 'classList') { + extract_from_jsx_attribute_value(attr.value, state); + } + } + } + next(); + }, + }; + + walk(ast as Node, state, visitors); +}; + +/** + * Second pass to extract from tracked variables in Svelte scripts. + */ +const extract_from_tracked_vars = (ast: AST.Root, state: WalkState): void => { + const scripts = [ast.instance?.content, ast.module?.content].filter(Boolean); + + for (const script of scripts) { + extract_from_tracked_vars_in_script(script as unknown as Node, state); + } +}; + +/** + * Second pass to extract from tracked variables in a standalone script AST. + * Used for both Svelte scripts and standalone TS/JS files (including JSX). + */ +const extract_from_tracked_vars_in_script = (ast: Node, state: WalkState): void => { + const find_visitors: Visitors = { + VariableDeclarator(node, {state}) { + const declarator = node as unknown as {id: {type: string; name: string}; init: unknown}; + if ( + declarator.id.type === 'Identifier' && + state.tracked_vars.has(declarator.id.name) && + !state.class_name_vars.has(declarator.id.name) && + declarator.init + ) { + extract_from_expression(declarator.init as AST.SvelteNode, state); + } + }, + }; + + walk(ast, state, find_visitors); +}; diff --git a/src/lib/css_class_generation.ts b/src/lib/css_class_generation.ts new file mode 100644 index 000000000..22c79f14d --- /dev/null +++ b/src/lib/css_class_generation.ts @@ -0,0 +1,221 @@ +/** + * CSS class generation utilities. + * + * Produces CSS output from class definitions, handles interpretation of + * dynamic classes, and provides collection management for extracted classes. + * + * @module + */ + +import type {Logger} from '@fuzdev/fuz_util/log.js'; + +import { + type SourceLocation, + type CssClassDiagnostic, + type GenerationDiagnostic, + create_generation_diagnostic, +} from './diagnostics.js'; +import {parse_ruleset, is_single_selector_ruleset} from './css_ruleset_parser.js'; + +// +// CSS Utilities +// + +/** + * Escapes special characters in a CSS class name for use in a selector. + * CSS selectors require escaping of characters like `:`, `%`, `(`, `)`, etc. + * + * @example + * escape_css_selector('display:flex') // 'display\\:flex' + * escape_css_selector('opacity:80%') // 'opacity\\:80\\%' + * escape_css_selector('nth-child(2n)') // 'nth-child\\(2n\\)' + */ +export const escape_css_selector = (name: string): string => { + return name.replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, '\\$&'); +}; + +// +// Class Definitions +// + +export type CssClassDefinition = + | CssClassDefinitionItem + | CssClassDefinitionGroup + | CssClassDefinitionInterpreter; + +export interface CssClassDefinitionBase { + comment?: string; +} + +export interface CssClassDefinitionItem extends CssClassDefinitionBase { + declaration: string; +} + +export interface CssClassDefinitionGroup extends CssClassDefinitionBase { + ruleset: string; +} + +/** + * Context passed to CSS class interpreters. + * Provides access to logging, diagnostics collection, and the class registry. + */ +export interface CssClassInterpreterContext { + /** Optional logger for warnings/errors */ + log?: Logger; + /** Diagnostics array to collect warnings and errors */ + diagnostics: Array; + /** All known CSS class definitions (token + composite classes) */ + class_definitions: Record; + /** Valid CSS properties for literal validation, or null to skip validation */ + css_properties: Set | null; +} + +export interface CssClassDefinitionInterpreter extends CssClassDefinitionBase { + pattern: RegExp; + interpret: (matched: RegExpMatchArray, ctx: CssClassInterpreterContext) => string | null; +} + +// +// CSS Generation +// + +/** + * Result from CSS class generation. + */ +export interface GenerateClassesCssResult { + css: string; + diagnostics: Array; +} + +export interface GenerateClassesCssOptions { + class_names: Iterable; + class_definitions: Record; + interpreters: Array; + /** Valid CSS properties for literal validation, or null to skip validation */ + css_properties: Set | null; + log?: Logger; + class_locations?: Map | null>; +} + +export const generate_classes_css = ( + options: GenerateClassesCssOptions, +): GenerateClassesCssResult => { + const {class_names, class_definitions, interpreters, css_properties, log, class_locations} = + options; + const interpreter_diagnostics: Array = []; + const diagnostics: Array = []; + + // Create interpreter context with access to all class definitions + const ctx: CssClassInterpreterContext = { + log, + diagnostics: interpreter_diagnostics, + class_definitions, + css_properties, + }; + + // Create a map that has the index of each class name as the key + const indexes: Map = new Map(); + const keys = Object.keys(class_definitions); + for (let i = 0; i < keys.length; i++) { + indexes.set(keys[i]!, i); + } + + // If any classes are unknown, just put them at the end + const sorted_classes = (Array.isArray(class_names) ? class_names : Array.from(class_names)).sort( + (a, b) => { + const index_a = indexes.get(a) ?? Number.MAX_VALUE; + const index_b = indexes.get(b) ?? Number.MAX_VALUE; + if (index_a !== index_b) return index_a - index_b; + return a.localeCompare(b); // alphabetic tiebreaker for stable sort + }, + ); + + let css = ''; + for (const c of sorted_classes) { + let v = class_definitions[c]; + + // Track diagnostics count before this class + const diag_count_before = interpreter_diagnostics.length; + + // If not found statically, try interpreters + if (!v) { + for (const interpreter of interpreters) { + const matched = c.match(interpreter.pattern); + if (matched) { + const result = interpreter.interpret(matched, ctx); + if (result) { + // Check if the result is a full ruleset (contains braces) + // or just a declaration + if (result.includes('{')) { + // Full ruleset - use as-is + v = {ruleset: result, comment: interpreter.comment}; + } else { + // Simple declaration + v = {declaration: result, comment: interpreter.comment}; + } + break; + } + } + } + } + + // Convert any new interpreter diagnostics to GenerationDiagnostic with locations + for (let i = diag_count_before; i < interpreter_diagnostics.length; i++) { + const diag = interpreter_diagnostics[i]!; + const locations = class_locations?.get(diag.class_name) ?? null; + diagnostics.push(create_generation_diagnostic(diag, locations)); + } + + if (!v) { + continue; + } + + const {comment} = v; + + if (comment) { + const trimmed = comment.trim(); + if (trimmed.includes('\n')) { + // Multi-line CSS comment + const lines = trimmed.split('\n').map((line) => line.trim()); + css += `/*\n${lines.join('\n')}\n*/\n`; + } else { + css += `/* ${trimmed} */\n`; + } + } + + if ('declaration' in v) { + css += `.${escape_css_selector(c)} { ${v.declaration} }\n`; + } else if ('ruleset' in v) { + css += v.ruleset.trim() + '\n'; + + // Warn if this ruleset could be converted to declaration format + try { + const parsed = parse_ruleset(v.ruleset); + if (is_single_selector_ruleset(parsed.rules, c)) { + diagnostics.push({ + phase: 'generation', + level: 'warning', + message: `Ruleset "${c}" has a single selector and could be converted to declaration format for modifier support`, + class_name: c, + suggestion: `Convert to: { declaration: '${parsed.rules[0]?.declarations.replace(/\s+/g, ' ').trim()}' }`, + locations: class_locations?.get(c) ?? null, + }); + } + } catch (e) { + // Warn about parse errors so users can investigate + const error_message = e instanceof Error ? e.message : String(e); + diagnostics.push({ + phase: 'generation', + level: 'warning', + message: `Failed to parse ruleset for "${c}": ${error_message}`, + class_name: c, + suggestion: 'Check for CSS syntax errors in the ruleset', + locations: class_locations?.get(c) ?? null, + }); + } + } + // Note: Interpreted types are converted to declaration above, so no else clause needed + } + + return {css, diagnostics}; +}; diff --git a/src/lib/css_class_generators.ts b/src/lib/css_class_generators.ts index 7e0f4f862..16e8ae285 100644 --- a/src/lib/css_class_generators.ts +++ b/src/lib/css_class_generators.ts @@ -1,14 +1,14 @@ -import type {CssClassDeclaration} from './css_class_helpers.js'; +import type {CssClassDefinition} from './css_class_generation.js'; -export type ClassTemplateResult = { +export type GeneratedClassResult = { name: string; css: string; } | null; export type ClassTemplateFn = - | ((v1: T1) => ClassTemplateResult) - | ((v1: T1, v2: T2) => ClassTemplateResult) - | ((v1: T1, v2: T2, v3: T3) => ClassTemplateResult); + | ((v1: T1) => GeneratedClassResult) + | ((v1: T1, v2: T2) => GeneratedClassResult) + | ((v1: T1, v2: T2, v3: T3) => GeneratedClassResult); /** * Generates CSS class declarations from templates. @@ -39,8 +39,8 @@ export const generate_classes = ( values: Iterable, secondary?: Iterable, tertiary?: Iterable, -): Record => { - const result: Record = {}; +): Record => { + const result: Record = {}; for (const v1 of values) { if (secondary) { @@ -70,10 +70,6 @@ export const generate_classes = ( return result; }; -// TODO refactor with `src/lib/variable_data.ts`, we may want `css_data.ts` or something -export const CSS_GLOBALS = ['inherit', 'initial', 'revert', 'revert_layer', 'unset'] as const; -export type CssGlobal = (typeof CSS_GLOBALS)[number]; - export const CSS_DIRECTIONS = ['top', 'right', 'bottom', 'left'] as const; export type CssDirection = (typeof CSS_DIRECTIONS)[number]; @@ -81,22 +77,8 @@ export type CssDirection = (typeof CSS_DIRECTIONS)[number]; export const COLOR_INTENSITIES = ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as const; export type ColorIntensity = (typeof COLOR_INTENSITIES)[number]; -// Helper to convert snake_case to kebab-case for CSS property values -export const to_kebab = (str: string): string => str.replace(/_/g, '-'); - // Helper to convert any string to a valid CSS variable name (snake_case) -export const to_variable_name = (str: string): string => str.replace(/[-\s]+/g, '_'); - -// Helper to generate global value classes for any CSS property -export const generate_global_classes = (property: string): Record => { - return generate_classes( - (global: (typeof CSS_GLOBALS)[number]) => ({ - name: `${to_variable_name(property)}_${global}`, - css: `${property}: ${to_kebab(global)};`, - }), - CSS_GLOBALS, - ); -}; +export const format_variable_name = (str: string): string => str.replace(/[-\s]+/g, '_'); /** * Format spacing values for CSS (handles 0, auto, percentages, pixels, and CSS variables). @@ -132,9 +114,8 @@ export const format_dimension_value = (value: string): string => { /** * Generate classes for a single CSS property with various values. - * This is the most common pattern, used by display, visibility, float, etc. * - * @param property - The CSS property name (e.g. 'display', 'gap') + * @param property - The CSS property name (e.g. 'font-size', 'gap') * @param values - The values to generate classes for * @param formatter - Optional function to format values (e.g. v => `var(--space_${v})`) * @param prefix - Optional class name prefix (defaults to property with dashes replaced by underscores) @@ -143,11 +124,11 @@ export const generate_property_classes = ( property: string, values: Iterable, formatter?: (value: string) => string, - prefix: string = to_variable_name(property), -): Record => { + prefix: string = format_variable_name(property), +): Record => { return generate_classes( (value: string) => ({ - name: `${prefix}_${to_variable_name(value)}`, + name: `${prefix}_${format_variable_name(value)}`, css: `${property}: ${formatter?.(value) ?? value};`, }), values, @@ -166,7 +147,7 @@ export const generate_directional_classes = ( property: string, values: Iterable, formatter?: (v: string) => string, -): Record => { +): Record => { const prefix = property[0]; // 'm' for margin, 'p' for padding return generate_classes( @@ -175,20 +156,29 @@ export const generate_directional_classes = ( // Map variants to their configurations const configs: Record = { - '': {name: `${prefix}_${to_variable_name(value)}`, css: `${property}: ${formatted};`}, - t: {name: `${prefix}t_${to_variable_name(value)}`, css: `${property}-top: ${formatted};`}, - r: {name: `${prefix}r_${to_variable_name(value)}`, css: `${property}-right: ${formatted};`}, + '': {name: `${prefix}_${format_variable_name(value)}`, css: `${property}: ${formatted};`}, + t: { + name: `${prefix}t_${format_variable_name(value)}`, + css: `${property}-top: ${formatted};`, + }, + r: { + name: `${prefix}r_${format_variable_name(value)}`, + css: `${property}-right: ${formatted};`, + }, b: { - name: `${prefix}b_${to_variable_name(value)}`, + name: `${prefix}b_${format_variable_name(value)}`, css: `${property}-bottom: ${formatted};`, }, - l: {name: `${prefix}l_${to_variable_name(value)}`, css: `${property}-left: ${formatted};`}, + l: { + name: `${prefix}l_${format_variable_name(value)}`, + css: `${property}-left: ${formatted};`, + }, x: { - name: `${prefix}x_${to_variable_name(value)}`, + name: `${prefix}x_${format_variable_name(value)}`, css: `${property}-left: ${formatted};\t${property}-right: ${formatted};`, }, y: { - name: `${prefix}y_${to_variable_name(value)}`, + name: `${prefix}y_${format_variable_name(value)}`, css: `${property}-top: ${formatted};\t${property}-bottom: ${formatted};`, }, }; @@ -200,30 +190,6 @@ export const generate_directional_classes = ( ); }; -/** - * Generate classes for properties with axis variants (e.g. overflow, overflow-x, overflow-y). - * - * @param property - The base CSS property name (e.g. 'overflow') - * @param values - The values to generate classes for - */ -export const generate_property_with_axes = ( - property: string, - values: Iterable, -): Record => { - return generate_classes( - (axis: string, value: string) => { - const prop = axis === '' ? property : `${property}-${axis}`; - const prefix = axis === '' ? property : `${property}_${axis}`; - return { - name: `${to_variable_name(prefix)}_${to_variable_name(value)}`, - css: `${prop}: ${value};`, - }; - }, - ['', 'x', 'y'], - values, - ); -}; - /** * Generate border radius corner classes for all four corners. * Creates classes for top-left, top-right, bottom-left, bottom-right corners. @@ -234,7 +200,7 @@ export const generate_property_with_axes = ( export const generate_border_radius_corners = ( values: Iterable, formatter?: (value: string) => string, -): Record => { +): Record => { const corners = [ {prop: 'border-top-left-radius', name: 'border_top_left_radius'}, {prop: 'border-top-right-radius', name: 'border_top_right_radius'}, @@ -244,7 +210,7 @@ export const generate_border_radius_corners = ( return generate_classes( (corner: (typeof corners)[0], value: string) => ({ - name: `${corner.name}_${to_variable_name(value)}`, + name: `${corner.name}_${format_variable_name(value)}`, css: `${corner.prop}: ${formatter?.(value) ?? value};`, }), corners, @@ -263,7 +229,7 @@ export const generate_border_radius_corners = ( export const generate_shadow_classes = ( sizes: Iterable, alpha_mapping: Record, -): Record => { +): Record => { const shadow_types = [ {prefix: 'shadow', var_prefix: 'shadow'}, {prefix: 'shadow_top', var_prefix: 'shadow_top'}, diff --git a/src/lib/css_class_helpers.ts b/src/lib/css_class_helpers.ts deleted file mode 100644 index 3051d076a..000000000 --- a/src/lib/css_class_helpers.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type {Logger} from '@fuzdev/fuz_util/log.js'; - -// TODO maybe just use the Svelte (and Oxc?) parser instead of this regexp madness? - -export interface CssExtractor { - matcher: RegExp; - mapper: (matched: RegExpExecArray) => Array; -} - -const CSS_CLASS_EXTRACTORS: Array = [ - // `class:a` - { - matcher: /(?]+)/gi, - mapper: (matched) => [matched[1]!], - }, // initial capture group is fake just because the second regexp uses a capture group for its back reference - - // `class="a"`, `classes="a"`, `classes = 'a b'`, `classes: 'a b'` with any whitespace around the `=`/`:` - { - matcher: /(? - matched[2]! - .replace( - // omit all expressions with best-effort - it's not perfect especially - // around double quote strings in JS in Svelte expressions, but using single quotes is better imo - /\S*{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*}\S*/g, - // same failures: - // /\S*{(?:[^{}]*|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.|\$\{(?:[^{}]*|{[^{}]*})*\})*`|{(?:[^{}]*|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.|\$\{(?:[^{}]*|{[^{}]*})*\})*`)*})*}\S*/g, - // 3 failures: - // /\S*{(?:[^{}`'"]*|[`'"]((?:[^\\`'"]|\\.|\$\{[^}]*\})*)[`'"]|{[^{}]*})*}\S*/g, - '', - ) - .split(/\s+/) - .filter(Boolean), - }, - // arrays like `class: ['a', 'b']`, `classes = ['a', 'b']`, `classes={['a', 'b']` - { - matcher: /(? => { - const content = matched[1]!; - if (content.includes('[')) return []; // TODO @many ideally fix instead of bailing, but maybe we need a real JS parser? - const items = content.split(',').map((item) => item.trim()); - - return items - .reduce((classes: Array, item: string) => { - // Match string literals within each item - const string_literals = item.match(/(['"`])((?:(?!\1)[^\\]|\\.)*?)\1/g); - if (!string_literals) return classes; - - // Check if the item contains concatenation - const has_concatenation = /\s*\+\s*/.test(item); - - if (!has_concatenation && string_literals.length === 1) { - const content = string_literals[0].slice(1, -1); // remove quotes - if (!content.includes('${')) { - classes.push(content.replace(/\\(['"`])/g, '$1').trim()); - } - } - - return classes; - }, []) - .filter(Boolean); // Filter out empty strings - }, - }, -]; - -/** - * Returns a Set of CSS classes from a string of HTML/Svelte/JS/TS content. - * Handles class attributes, directives, and various forms of CSS class declarations. - */ -export const collect_css_classes = ( - contents: string, - extractors: Array = CSS_CLASS_EXTRACTORS, -): Set => { - const all_classes: Set = new Set(); - - for (const extractor of extractors) { - for (const c of extract_classes(contents, extractor)) { - all_classes.add(c); - } - } - - return all_classes; -}; - -const extract_classes = (contents: string, {matcher, mapper}: CssExtractor): Set => { - const classes: Set = new Set(); - let matched: RegExpExecArray | null; - while ((matched = matcher.exec(contents)) !== null) { - for (const c of mapper(matched)) { - classes.add(c); - } - } - return classes; -}; - -export class CssClasses { - include_classes: Set | null; - - #all: Set = new Set(); - - #by_id: Map> = new Map(); - - #dirty = true; - - constructor(include_classes: Set | null = null) { - this.include_classes = include_classes; - } - - add(id: string, classes: Set): void { - this.#dirty = true; - this.#by_id.set(id, classes); - } - - delete(id: string): void { - this.#dirty = true; - this.#by_id.delete(id); - } - - get(): Set { - if (this.#dirty) { - this.#dirty = false; - this.#recalculate(); - } - return this.#all; - } - - #recalculate(): void { - this.#all.clear(); - if (this.include_classes) { - for (const c of this.include_classes) { - this.#all.add(c); - } - } - for (const classes of this.#by_id.values()) { - for (const c of classes) { - this.#all.add(c); - } - } - } -} - -export type CssClassDeclaration = - | CssClassDeclarationItem - | CssClassDeclarationGroup - | CssClassDeclarationInterpreter; - -export interface CssClassDeclarationBase { - comment?: string; -} - -export interface CssClassDeclarationItem extends CssClassDeclarationBase { - declaration: string; -} -export interface CssClassDeclarationGroup extends CssClassDeclarationBase { - ruleset: string; -} -export interface CssClassDeclarationInterpreter extends CssClassDeclarationBase { - pattern: RegExp; - interpret: (matched: RegExpMatchArray, log?: Logger) => string | null; -} - -export const generate_classes_css = ( - classes: Iterable, - classes_by_name: Record, - interpreters: Array, - log?: Logger, -): string => { - // TODO when the API is redesigned this kind of thing should be cached - // Create a map that has the index of each class name as the key - const indexes: Map = new Map(); - const keys = Object.keys(classes_by_name); - for (let i = 0; i < keys.length; i++) { - indexes.set(keys[i]!, i); - } - - // If any classes are unknown, just put them at the end - const sorted_classes = (Array.isArray(classes) ? classes : Array.from(classes)).sort((a, b) => { - const index_a = indexes.get(a) ?? Number.MAX_VALUE; - const index_b = indexes.get(b) ?? Number.MAX_VALUE; - if (index_a !== index_b) return index_a - index_b; - return a.localeCompare(b); // alphabetic tiebreaker for stable sort - }); - - let css = ''; - for (const c of sorted_classes) { - let v = classes_by_name[c]; - - // If not found statically, try interpreters - if (!v) { - for (const interpreter of interpreters) { - const matched = c.match(interpreter.pattern); - if (matched) { - const declaration = interpreter.interpret(matched, log); - if (declaration) { - v = {declaration, comment: interpreter.comment}; - break; - } - } - } - } - - if (!v) { - // diagnostic - // if (!/^[a-z_0-9]+$/.test(c)) { - // console.error('invalid class detected, fix the regexps', c); - // } - continue; - } - - const {comment} = v; - - if (comment) { - css += comment.includes('\n') ? `/*\n${comment}\n*/\n` : `/** ${comment} */\n`; - } - - if ('declaration' in v) { - css += `.${c} { ${v.declaration} }\n`; - } else if ('ruleset' in v) { - css += v.ruleset + '\n'; - } - // Note: Interpreted types are converted to declaration above, so no else clause needed - } - - return css; -}; diff --git a/src/lib/css_class_interpreters.ts b/src/lib/css_class_interpreters.ts index 9139bb22f..807562854 100644 --- a/src/lib/css_class_interpreters.ts +++ b/src/lib/css_class_interpreters.ts @@ -1,99 +1,166 @@ -import type {CssClassDeclarationInterpreter} from './css_class_helpers.js'; -import {Z_INDEX_MAX} from './variable_data.js'; +import {escape_css_selector, type CssClassDefinitionInterpreter} from './css_class_generation.js'; +import { + is_possible_css_literal, + interpret_css_literal, + generate_css_literal_simple, + extract_segments, + extract_and_validate_modifiers, + type CssLiteralOutput, +} from './css_literal.js'; +import {generate_modified_ruleset} from './css_ruleset_parser.js'; /** - * Interpreter for opacity classes (opacity_0 through opacity_100). + * Interpreter for modified token/composite classes (e.g., `hover:p_md`, `md:box`, `dark:hover:panel`). + * Applies modifiers to existing declaration-based or ruleset-based classes. + * + * This interpreter must run BEFORE css_literal_interpreter to handle cases like `hover:box` + * where `box` is a known class (not a CSS property). */ -export const opacity_interpreter: CssClassDeclarationInterpreter = { - pattern: /^opacity_(\d+)$/, - interpret: (matched, log) => { - const value = parseInt(matched[1]!, 10); - if (value < 0 || value > 100) { - log?.warn(`Invalid opacity value: ${value}. Must be between 0 and 100.`); +export const modified_class_interpreter: CssClassDefinitionInterpreter = { + pattern: /^.+:.+$/, + interpret: (matched, ctx) => { + const class_name = matched[0]; + const segments = extract_segments(class_name); + + // Extract modifiers from the front + const result = extract_and_validate_modifiers(segments, class_name); + + if (!result.ok) { + // Modifier validation error - let css_literal_interpreter try return null; } - return `opacity: ${value === 0 ? '0' : value === 100 ? '1' : `${value}%`};`; - }, - // comment: 'Interpreted opacity value', -}; -/** - * Interpreter for font-weight classes, - * `font_weight_1` through `font_weight_1000` following the CSS spec. - */ -export const font_weight_interpreter: CssClassDeclarationInterpreter = { - pattern: /^font_weight_(\d+)$/, - interpret: (matched, log) => { - const value = parseInt(matched[1]!, 10); - if (value < 1 || value > 1000) { - log?.warn(`Invalid font-weight value: ${value}. Must be between 1 and 1000.`); + const {modifiers, remaining} = result; + + // Must have exactly one remaining segment (the base class name) + if (remaining.length !== 1) { return null; } - return `font-weight: ${value}; --font_weight: ${value};`; - }, -}; -/** - * Interpreter for border-radius percentage classes, - * `border_radius_0` through `border_radius_100`. - */ -export const border_radius_interpreter: CssClassDeclarationInterpreter = { - pattern: /^border_radius_(\d+)$/, - interpret: (matched, log) => { - const value = parseInt(matched[1]!, 10); - if (value < 0 || value > 100) { - log?.warn(`Invalid border-radius percentage: ${value}. Must be between 0 and 100.`); + const base_class_name = remaining[0]!; + + // Check if the base class is known + const base_class = ctx.class_definitions[base_class_name]; + if (!base_class) { return null; } - return `border-radius: ${value === 0 ? '0' : `${value}%`};`; - }, -}; -/** - * Interpreter for border radius corner percentage classes, - * handles all four corners: top-left, top-right, bottom-left, bottom-right. - * Examples: `border_top_left_radius_50`, `border_bottom_right_radius_100`. - */ -export const border_radius_corners_interpreter: CssClassDeclarationInterpreter = { - pattern: /^border_(top|bottom)_(left|right)_radius_(\d+)$/, - interpret: (matched, log) => { - const vertical = matched[1]!; - const horizontal = matched[2]!; - const value = parseInt(matched[3]!, 10); - if (value < 0 || value > 100) { - log?.warn( - `Invalid border-${vertical}-${horizontal}-radius percentage: ${value}. Must be between 0 and 100.`, - ); + // Must have at least one modifier (otherwise it's just the base class) + const has_modifiers = + modifiers.media || + modifiers.ancestor || + modifiers.states.length > 0 || + modifiers.pseudo_element; + if (!has_modifiers) { return null; } - return `border-${vertical}-${horizontal}-radius: ${value === 0 ? '0' : `${value}%`};`; + + const escaped_class_name = escape_css_selector(class_name); + + // Build state and pseudo-element CSS suffixes + let state_css = ''; + for (const state of modifiers.states) { + state_css += state.css; + } + const pseudo_element_css = modifiers.pseudo_element?.css ?? ''; + + // Handle declaration-based classes + if ('declaration' in base_class) { + // Build the selector + let selector = `.${escaped_class_name}`; + selector += state_css; + selector += pseudo_element_css; + + // Create output compatible with generate_css_literal_simple + const output: CssLiteralOutput = { + declaration: base_class.declaration, + selector, + media_wrapper: modifiers.media?.css ?? null, + ancestor_wrapper: modifiers.ancestor?.css ?? null, + }; + + const css = generate_css_literal_simple(output); + return css.trimEnd(); + } + + // Handle ruleset-based classes + if ('ruleset' in base_class) { + const result = generate_modified_ruleset( + base_class.ruleset, + base_class_name, + escaped_class_name, + state_css, + pseudo_element_css, + modifiers.media?.css ?? null, + modifiers.ancestor?.css ?? null, + ); + + // Emit warnings for skipped modifiers + for (const skipped of result.skipped_modifiers) { + if (skipped.reason === 'pseudo_element_conflict') { + ctx.diagnostics.push({ + level: 'warning', + class_name, + message: `Rule "${skipped.selector}" already contains a pseudo-element; "${skipped.conflicting_modifier}" modifier was not applied`, + suggestion: `The rule is included with just the class renamed`, + }); + } else { + ctx.diagnostics.push({ + level: 'warning', + class_name, + message: `Rule "${skipped.selector}" already contains "${skipped.conflicting_modifier}"; modifier was not applied to avoid redundancy`, + suggestion: `The rule is included with just the class renamed`, + }); + } + } + + return result.css.trimEnd(); + } + + return null; }, }; /** - * Interpreter for z-index classes, - * `z_index_0` through `z_index_${Z_INDEX_MAX}` (max CSS z-index). + * Interpreter for CSS-literal classes (e.g., `display:flex`, `hover:opacity:80%`). + * Generates full CSS rulesets including any modifier wrappers. */ -export const z_index_interpreter: CssClassDeclarationInterpreter = { - pattern: /^z_index_(\d+)$/, - interpret: (matched, log) => { - const value = parseInt(matched[1]!, 10); - if (value < 0 || value > Z_INDEX_MAX) { - log?.warn(`Invalid z-index value: ${value}. Must be between 0 and ${Z_INDEX_MAX}.`); +export const css_literal_interpreter: CssClassDefinitionInterpreter = { + pattern: /^.+:.+$/, + interpret: (matched, ctx) => { + const class_name = matched[0]; + + if (!is_possible_css_literal(class_name)) { + return null; + } + + const escaped_class_name = escape_css_selector(class_name); + const result = interpret_css_literal(class_name, escaped_class_name, ctx.css_properties); + + if (!result.ok) { + ctx.diagnostics.push(result.error); return null; } - return `z-index: ${value};`; + + // Collect warnings for upstream handling + for (const warning of result.warnings) { + ctx.diagnostics.push(warning); + } + + // Generate the full CSS including any wrappers + const css = generate_css_literal_simple(result.output); + + // Return the CSS but strip trailing newline for consistency + return css.trimEnd(); }, }; /** * Collection of all builtin interpreters for dynamic CSS class generation. + * Order matters: modified_class_interpreter runs first to handle `hover:box` before + * css_literal_interpreter tries to interpret it as `hover:box` (property:value). */ -export const css_class_interpreters: Array = [ - opacity_interpreter, - font_weight_interpreter, - border_radius_interpreter, - border_radius_corners_interpreter, - z_index_interpreter, - // add new default interpreters here +export const css_class_interpreters: Array = [ + modified_class_interpreter, + css_literal_interpreter, ]; diff --git a/src/lib/css_classes.ts b/src/lib/css_classes.ts index c9c7cae27..58337bdc8 100644 --- a/src/lib/css_classes.ts +++ b/src/lib/css_classes.ts @@ -1,407 +1,154 @@ -import type {CssClassDeclaration} from './css_class_helpers.js'; -import { - generate_classes, - to_kebab, - CSS_GLOBALS, - COLOR_INTENSITIES, - generate_property_classes, - generate_directional_classes, - generate_property_with_axes, - generate_border_radius_corners, - generate_shadow_classes, - format_spacing_value, - format_dimension_value, -} from './css_class_generators.js'; -import { - space_variants, - color_variants, - text_color_variants, - font_size_variants, - icon_size_variants, - line_height_variants, - border_radius_variants, - border_width_variants, - alignment_values, - justify_values, - overflow_values, - border_style_values, - display_values, - text_align_values, - vertical_align_values, - word_break_values, - position_values, - visibility_values, - float_values, - flex_wrap_values, - flex_direction_values, - overflow_wrap_values, - scrollbar_width_values, - scrollbar_gutter_values, - shadow_semantic_values, - shadow_alpha_variants, -} from './variable_data.js'; -import {css_class_composites} from './css_class_composites.js'; - -// TODO add animation support, either as a separate thing or rename `css_classes_by_name` to be more generic, like `css_by_name` - need to collect `animation: foo ...` names like we do classes - -// TODO think about variable support (much harder problem, need dependency graph) - -// TODO modifiers for :hover/:active/:focus (how? do we need to give up the compat with JS identifier names?) - /** - * @see `generate_classes_css` + * Collection management for extracted CSS classes. + * + * Tracks classes per-file for efficient incremental updates during watch mode. + * Uses `null` instead of empty collections to avoid allocation overhead. + * + * @module */ -export const css_classes_by_name: Record = { - // Composite classes go first, so they can be overridden by the more specific classes. - ...css_class_composites, - - ...generate_property_classes('position', position_values), - ...generate_property_classes('position', CSS_GLOBALS, to_kebab), - - ...generate_property_classes('display', display_values), - ...generate_property_classes('display', CSS_GLOBALS, to_kebab), - - ...generate_property_classes('visibility', visibility_values), - ...generate_property_classes('visibility', CSS_GLOBALS, to_kebab), - - ...generate_property_classes('float', float_values), - ...generate_property_classes('float', CSS_GLOBALS, to_kebab), - - ...generate_property_with_axes('overflow', overflow_values), - - ...generate_property_classes('overflow-wrap', overflow_wrap_values), - ...generate_property_classes('overflow-wrap', CSS_GLOBALS, to_kebab, 'overflow_wrap'), - - ...generate_property_classes('scrollbar-width', scrollbar_width_values), - ...generate_property_classes('scrollbar-width', CSS_GLOBALS, to_kebab, 'scrollbar_width'), - - ...generate_property_classes('scrollbar-gutter', scrollbar_gutter_values), - ...generate_property_classes('scrollbar-gutter', CSS_GLOBALS, to_kebab, 'scrollbar_gutter'), - - flex_1: {declaration: 'flex: 1;'}, - ...generate_property_classes('flex-wrap', flex_wrap_values), - ...generate_property_classes('flex-wrap', CSS_GLOBALS, to_kebab), - ...generate_property_classes('flex-direction', flex_direction_values), - ...generate_property_classes('flex-direction', CSS_GLOBALS, to_kebab), - flex_grow_1: {declaration: 'flex-grow: 1;'}, - flex_grow_0: {declaration: 'flex-grow: 0;'}, - flex_shrink_1: {declaration: 'flex-shrink: 1;'}, - flex_shrink_0: {declaration: 'flex-shrink: 0;'}, - - ...generate_property_classes('align-items', alignment_values), - ...generate_property_classes('align-content', [ - ...alignment_values, - 'space-between', - 'space-around', - 'space-evenly', - ]), - ...generate_property_classes('align-self', alignment_values), - ...generate_property_classes('justify-content', justify_values), - ...generate_property_classes('justify-items', [ - 'center', - 'start', - 'end', - 'left', - 'right', - 'baseline', - 'stretch', - ]), - ...generate_property_classes('justify-self', [ - 'center', - 'start', - 'end', - 'left', - 'right', - 'baseline', - 'stretch', - ]), - flip_x: {declaration: 'transform: scaleX(-1);'}, - flip_y: {declaration: 'transform: scaleY(-1);'}, - flip_xy: {declaration: 'transform: scaleX(-1) scaleY(-1);'}, - - font_family_sans: {declaration: 'font-family: var(--font_family_sans);'}, - font_family_serif: {declaration: 'font-family: var(--font_family_serif);'}, - font_family_mono: {declaration: 'font-family: var(--font_family_mono);'}, - - ...generate_property_classes('line-height', ['0', '1', ...line_height_variants], (v) => - v === '0' || v === '1' ? v : `var(--line_height_${v})`, - ), - ...generate_property_classes( - 'font-size', - font_size_variants, - (v) => `var(--font_size_${v}); --font_size: var(--font_size_${v})`, - ), - ...generate_property_classes( - 'font-size', - icon_size_variants, - (v) => `var(--icon_size_${v}); --font_size: var(--icon_size_${v})`, - 'icon_size', - ), - // TODO some of these need to be filled out and include CSS_GLOBALS (but maybe the API should be opt-out?) - ...generate_property_classes('text-align', text_align_values), - ...generate_property_classes('vertical-align', vertical_align_values), - ...generate_property_classes('word-break', word_break_values), - ...generate_property_classes('word-break', CSS_GLOBALS, to_kebab), - ...generate_property_classes('white-space', [ - 'normal', - 'nowrap', - 'pre', - 'pre-wrap', - 'pre-line', - 'break-spaces', - ]), - ...generate_property_classes('white-space-collapse', [ - 'collapse', - 'preserve', - 'preserve-breaks', - 'preserve-spaces', - 'break-spaces', - ]), - ...generate_property_classes('white-space-collapse', CSS_GLOBALS, to_kebab), - ...generate_property_classes('text-wrap', ['wrap', 'nowrap', 'balance', 'pretty', 'stable']), - ...generate_property_classes('user-select', ['none', 'auto', 'text', 'all']), - ...generate_property_classes('user-select', CSS_GLOBALS, to_kebab), - - /* - colors +import type {SourceLocation, ExtractionDiagnostic} from './diagnostics.js'; - */ - ...generate_property_classes( - 'color', - text_color_variants.map(String), - (v) => `var(--text_color_${v})`, - 'text_color', - ), - ...generate_property_classes( - 'background-color', - [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), - (v) => `var(--darken_${v})`, - 'darken', - ), - ...generate_property_classes( - 'background-color', - [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), - (v) => `var(--lighten_${v})`, - 'lighten', - ), - bg: {declaration: 'background-color: var(--bg);'}, - fg: {declaration: 'background-color: var(--fg);'}, - ...generate_property_classes( - 'background-color', - [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), - (v) => `var(--bg_${v})`, - 'bg', - ), - ...generate_property_classes( - 'background-color', - [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), - (v) => `var(--fg_${v})`, - 'fg', - ), - ...generate_property_classes( - 'color', - [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), - (v) => `var(--darken_${v})`, - 'color_darken', - ), - ...generate_property_classes( - 'color', - [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), - (v) => `var(--lighten_${v})`, - 'color_lighten', - ), - color_bg: {declaration: 'color: var(--bg);'}, - color_fg: {declaration: 'color: var(--fg);'}, - ...generate_property_classes( - 'color', - [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), - (v) => `var(--bg_${v})`, - 'color_bg', - ), - ...generate_property_classes( - 'color', - [1, 2, 3, 4, 5, 6, 7, 8, 9].map(String), - (v) => `var(--fg_${v})`, - 'color_fg', - ), - ...generate_classes( - (hue: string) => ({ - name: `hue_${hue}`, - css: `--hue: var(--hue_${hue});`, - }), - color_variants, - ), - ...generate_classes( - (hue: string, intensity: string) => ({ - name: `color_${hue}_${intensity}`, - css: `color: var(--color_${hue}_${intensity});`, - }), - color_variants, - COLOR_INTENSITIES, - ), - ...generate_classes( - (hue: string, intensity: string) => ({ - name: `bg_${hue}_${intensity}`, - css: `background-color: var(--color_${hue}_${intensity});`, - }), - color_variants, - COLOR_INTENSITIES, - ), - ...generate_property_classes( - 'border-color', - [1, 2, 3, 4, 5].map(String), - (v) => `var(--border_color_${v})`, - ), - ...generate_property_classes('border-color', color_variants, (v) => `var(--border_color_${v})`), - border_color_transparent: {declaration: 'border-color: transparent;'}, - ...generate_property_classes( - 'outline-color', - [1, 2, 3, 4, 5].map(String), - (v) => `var(--border_color_${v})`, - ), - ...generate_property_classes('outline-color', color_variants, (v) => `var(--border_color_${v})`), - outline_color_transparent: {declaration: 'outline-color: transparent;'}, - - ...generate_property_classes('border-width', ['0', ...border_width_variants.map(String)], (v) => - v === '0' ? '0' : `var(--border_width_${v})`, - ), - ...generate_property_classes('outline-width', ['0', ...border_width_variants.map(String)], (v) => - v === '0' ? '0' : `var(--border_width_${v})`, - ), - outline_width_focus: {declaration: 'outline-width: var(--outline_width_focus);'}, - outline_width_active: {declaration: 'outline-width: var(--outline_width_active);'}, - - ...generate_property_classes('border-style', border_style_values), - ...generate_property_classes('border-style', CSS_GLOBALS, to_kebab), - - ...generate_property_classes('outline-style', border_style_values), - ...generate_property_classes('outline-style', CSS_GLOBALS, to_kebab), - - ...generate_property_classes( - 'border-radius', - border_radius_variants, - (v) => `var(--border_radius_${v})`, - ), - ...generate_border_radius_corners(border_radius_variants, (v) => `var(--border_radius_${v})`), - - /* - - shadows - - */ - ...generate_shadow_classes(['xs', 'sm', 'md', 'lg', 'xl'], { - xs: '1', - sm: '2', - md: '3', - lg: '4', - xl: '5', - }), - ...generate_classes( - (value: string) => ({ - name: `shadow_color_${value}`, - css: `--shadow_color: var(--shadow_color_${value});`, - }), - shadow_semantic_values, - ), - ...generate_classes( - (hue: string) => ({ - name: `shadow_color_${hue}`, - css: `--shadow_color: var(--shadow_color_${hue});`, - }), - color_variants, - ), - ...generate_classes( - (alpha: number) => ({ - name: `shadow_alpha_${alpha}`, - css: `--shadow_alpha: var(--shadow_alpha_${alpha});`, - }), - shadow_alpha_variants, - ), - - /* higher specificity */ - /* TODO which order should these be in? */ - shadow_inherit: {declaration: 'box-shadow: inherit;'}, - shadow_none: {declaration: 'box-shadow: none;'}, - - /* - - layout - - */ - ...generate_property_classes( - 'width', - [ - '0', - '100', - '1px', - '2px', - '3px', - 'auto', - 'max-content', - 'min-content', - 'fit-content', - 'stretch', - ...space_variants, - ], - format_dimension_value, - ), - ...generate_property_classes( - 'height', - [ - '0', - '100', - '1px', - '2px', - '3px', - 'auto', - 'max-content', - 'min-content', - 'fit-content', - 'stretch', - ...space_variants, - ], - format_dimension_value, - ), - - ...generate_property_classes( - 'top', - ['0', '100', '1px', '2px', '3px', 'auto', ...space_variants], - format_spacing_value, - ), - ...generate_property_classes( - 'right', - ['0', '100', '1px', '2px', '3px', 'auto', ...space_variants], - format_spacing_value, - ), - ...generate_property_classes( - 'bottom', - ['0', '100', '1px', '2px', '3px', 'auto', ...space_variants], - format_spacing_value, - ), - ...generate_property_classes( - 'left', - ['0', '100', '1px', '2px', '3px', 'auto', ...space_variants], - format_spacing_value, - ), - - ...generate_property_classes( - 'inset', - ['0', '100', '1px', '2px', '3px', 'auto', ...space_variants], - format_spacing_value, - ), - - ...generate_directional_classes( - 'padding', - ['0', '100', '1px', '2px', '3px', ...space_variants], - format_spacing_value, - ), - ...generate_directional_classes( - 'margin', - ['0', '100', '1px', '2px', '3px', 'auto', ...space_variants], - format_spacing_value, - ), - ...generate_property_classes('gap', space_variants, format_spacing_value), - ...generate_property_classes('column-gap', space_variants, format_spacing_value), - ...generate_property_classes('row-gap', space_variants, format_spacing_value), -}; +/** + * Collection of CSS classes extracted from source files. + * Tracks classes per-file for efficient incremental updates. + * Uses `null` instead of empty collections to avoid allocation overhead. + */ +export class CssClasses { + include_classes: Set | null; + + #all: Set = new Set(); + + #all_with_locations: Map> = new Map(); + + /** Combined map with include_classes (null locations) and extracted classes (actual locations) */ + #all_with_locations_including_includes: Map | null> = new Map(); + + /** Classes by file id (files with no classes are not stored) */ + #by_id: Map>> = new Map(); + + /** Diagnostics stored per-file so they're replaced when a file is updated */ + #diagnostics_by_id: Map> = new Map(); + + #dirty = true; + + constructor(include_classes: Set | null = null) { + this.include_classes = include_classes; + } + + /** + * Adds extraction results for a file. + * Replaces any previous classes and diagnostics for this file. + * + * @param id - File identifier + * @param classes - Map of class names to their source locations, or null if none + * @param diagnostics - Extraction diagnostics from this file, or null if none + */ + add( + id: string, + classes: Map> | null, + diagnostics?: Array | null, + ): void { + this.#dirty = true; + if (classes) { + this.#by_id.set(id, classes); + } else { + this.#by_id.delete(id); + } + if (diagnostics) { + this.#diagnostics_by_id.set(id, diagnostics); + } else { + // Clear any old diagnostics for this file + this.#diagnostics_by_id.delete(id); + } + } + + delete(id: string): void { + this.#dirty = true; + this.#by_id.delete(id); + this.#diagnostics_by_id.delete(id); + } + + /** + * Gets all unique class names as a Set. + * For backward compatibility. + */ + get(): Set { + if (this.#dirty) { + this.#dirty = false; + this.#recalculate(); + } + return this.#all; + } + + /** + * Gets all classes with their source locations. + * Locations from include_classes are null. + */ + get_with_locations(): Map | null> { + if (this.#dirty) { + this.#dirty = false; + this.#recalculate(); + } + return this.#all_with_locations_including_includes; + } + + /** + * Gets all classes and their locations in a single call. + * More efficient than calling `get()` and `get_with_locations()` separately + * when both are needed (avoids potential double recalculation). + */ + get_all(): { + all_classes: Set; + all_classes_with_locations: Map | null>; + } { + if (this.#dirty) { + this.#dirty = false; + this.#recalculate(); + } + return { + all_classes: this.#all, + all_classes_with_locations: this.#all_with_locations_including_includes, + }; + } + + /** + * Gets all extraction diagnostics from all files. + */ + get_diagnostics(): Array { + const result: Array = []; + for (const diagnostics of this.#diagnostics_by_id.values()) { + result.push(...diagnostics); + } + return result; + } + + #recalculate(): void { + this.#all.clear(); + this.#all_with_locations.clear(); + this.#all_with_locations_including_includes.clear(); + + if (this.include_classes) { + for (const c of this.include_classes) { + this.#all.add(c); + this.#all_with_locations_including_includes.set(c, null); + } + } + + for (const classes of this.#by_id.values()) { + for (const [cls, locations] of classes) { + this.#all.add(cls); + const existing = this.#all_with_locations.get(cls); + if (existing) { + existing.push(...locations); + } else { + this.#all_with_locations.set(cls, [...locations]); + } + // Add to combined map only if not already from include_classes + if (!this.#all_with_locations_including_includes.has(cls)) { + this.#all_with_locations_including_includes.set(cls, this.#all_with_locations.get(cls)!); + } + } + } + } +} diff --git a/src/lib/css_literal.ts b/src/lib/css_literal.ts new file mode 100644 index 000000000..9a80755b7 --- /dev/null +++ b/src/lib/css_literal.ts @@ -0,0 +1,671 @@ +/** + * CSS-literal syntax parser, validator, and interpreter. + * + * Enables writing utility classes using actual CSS syntax: + * + * - `display:flex` → `.display\:flex { display: flex; }` + * - `hover:opacity:80%` → `.hover\:opacity\:80\%:hover { opacity: 80%; }` + * - `md:dark:hover:before:opacity:80%` → nested CSS with media query, ancestor, state, pseudo-element + * + * @see {@link https://github.com/fuzdev/fuz_css} for documentation + * @module + */ + +import {levenshtein_distance} from '@fuzdev/fuz_util/string.js'; + +import {type CssClassDiagnostic} from './diagnostics.js'; +import {get_modifier, get_all_modifier_names, type ModifierDefinition} from './modifiers.js'; + +// +// Types +// + +/** + * Parsed CSS-literal class with all components extracted. + */ +export interface ParsedCssLiteral { + /** Original class name */ + class_name: string; + /** Media modifier (breakpoint or feature query) */ + media: ModifierDefinition | null; + /** Ancestor modifier (dark/light) */ + ancestor: ModifierDefinition | null; + /** State modifiers in alphabetical order (can have multiple) */ + states: Array; + /** Pseudo-element modifier (before, after, etc.) */ + pseudo_element: ModifierDefinition | null; + /** CSS property name */ + property: string; + /** CSS value (with ~ replaced by spaces) */ + value: string; +} + +/** + * Result of parsing a CSS-literal class name. + * + * Uses a discriminated union (Result type) because parsing a single class + * is binary: it either succeeds or fails entirely. This differs from + * {@link ExtractionResult} which uses embedded diagnostics because file + * extraction can partially succeed (some classes extracted, others have errors). + */ +export type CssLiteralParseResult = + | {ok: true; parsed: ParsedCssLiteral; diagnostics: Array} + | {ok: false; error: CssClassDiagnostic}; + +/** + * Extracted modifiers from a class name. + * Used by both CSS-literal parsing and modified class interpretation. + */ +export interface ExtractedModifiers { + /** Media modifier (breakpoint or feature query) */ + media: ModifierDefinition | null; + /** Ancestor modifier (dark/light) */ + ancestor: ModifierDefinition | null; + /** State modifiers in alphabetical order (can have multiple) */ + states: Array; + /** Pseudo-element modifier (before, after, etc.) */ + pseudo_element: ModifierDefinition | null; +} + +/** + * Result of extracting modifiers from segments. + */ +export type ModifierExtractionResult = + | {ok: true; modifiers: ExtractedModifiers; remaining: Array} + | {ok: false; error: CssClassDiagnostic}; + +/** + * Result of interpreting a CSS-literal class. + */ +export type InterpretCssLiteralResult = + | {ok: true; output: CssLiteralOutput; warnings: Array} + | {ok: false; error: CssClassDiagnostic}; + +// +// CSS Property Validation +// + +/** + * Loads CSS properties from @webref/css. + * Returns a fresh Set each time - callers should cache the result if needed. + */ +export const load_css_properties = async (): Promise> => { + const webref = await import('@webref/css'); + const indexed = await webref.default.index(); + return new Set(Object.keys(indexed.properties)); +}; + +/** + * Checks if a property name is a valid CSS property. + * Custom properties (--*) always return true. + * + * @param property - The CSS property name to validate + * @param properties - Set of valid CSS properties from `load_css_properties()`. + * Pass `null` to skip validation. + * @returns True if valid CSS property or custom property + */ +export const is_valid_css_property = ( + property: string, + properties: Set | null, +): boolean => { + // Custom properties are always valid + if (property.startsWith('--')) return true; + + // If no properties provided, skip validation + if (!properties) return true; + + return properties.has(property); +}; + +/** + * Suggests a correct property name for a typo using Levenshtein distance. + * + * @param typo - The mistyped property name + * @param properties - Set of valid CSS properties from `load_css_properties()`. + * Pass `null` to skip suggestions. + * @returns The suggested property or null if no close match (Levenshtein distance > 2) + */ +export const suggest_css_property = ( + typo: string, + properties: Set | null, +): string | null => { + if (!properties) return null; + + let best_match: string | null = null; + let best_distance = 3; // Only suggest if distance <= 2 + + for (const prop of properties) { + const distance = levenshtein_distance(typo, prop); + if (distance < best_distance) { + best_distance = distance; + best_match = prop; + } + } + + return best_match; +}; + +/** + * Suggests a correct modifier name for a typo using Levenshtein distance. + * + * @param typo - The mistyped modifier name + * @returns The suggested modifier or null if no close match (Levenshtein distance > 2) + */ +export const suggest_modifier = (typo: string): string | null => { + const all_names = get_all_modifier_names(); + let best_match: string | null = null; + let best_distance = 3; + + for (const name of all_names) { + const distance = levenshtein_distance(typo, name); + if (distance < best_distance) { + best_distance = distance; + best_match = name; + } + } + + return best_match; +}; + +// +// Value Formatting +// + +/** + * Formats a CSS-literal value for CSS output. + * - Replaces `~` with space + * - Ensures space before `!important` + * + * @param value - Raw value from class name + * @returns Formatted CSS value + */ +export const format_css_value = (value: string): string => { + let result = value.replace(/~/g, ' '); + result = result.replace(/!important$/, ' !important'); + return result; +}; + +/** + * Pattern to detect calc expressions possibly missing spaces around + or -. + * CSS requires spaces around + and - in calc(). + */ +const CALC_MISSING_SPACE_PATTERN = /calc\([^)]*\d+[%a-z]*[+-]\d/i; + +/** + * Checks if a value contains a possibly invalid calc expression. + * + * @param value - The formatted CSS value + * @returns Warning message if suspicious, null otherwise + */ +export const check_calc_expression = (value: string): string | null => { + if (CALC_MISSING_SPACE_PATTERN.test(value)) { + return `Possible invalid calc expression. CSS requires spaces around + and - in calc(). Use ~ for spaces: "calc(100%~-~20px)"`; + } + return null; +}; + +// +// Parsing +// + +/** + * Checks if a class name could be a CSS-literal class. + * Quick check before attempting full parse. + * + * @param class_name - The class name to check + * @returns True if it could be CSS-literal syntax + */ +export const is_possible_css_literal = (class_name: string): boolean => { + // Must contain at least one colon + if (!class_name.includes(':')) return false; + + // Should not match existing class patterns (underscore-based) + if (/^[a-z_]+_[a-z0-9_]+$/.test(class_name)) return false; + + // Basic structure check: at minimum "property:value" + const parts = class_name.split(':'); + if (parts.length < 2) return false; + + // First part should be non-empty (property or first modifier) + if (parts[0] === '') return false; + + // Last part should be the value, shouldn't be empty + if (parts[parts.length - 1] === '') return false; + + return true; +}; + +/** + * Extracts colon-separated segments from a class name, handling parentheses. + * Parenthesized content (like function arguments) is kept intact. + * + * @example + * extract_segments('md:hover:display:flex') → ['md', 'hover', 'display', 'flex'] + * extract_segments('nth-child(2n+1):color:red') → ['nth-child(2n+1)', 'color', 'red'] + * extract_segments('width:calc(100%-20px)') → ['width', 'calc(100%-20px)'] + */ +export const extract_segments = (class_name: string): Array => { + const segments: Array = []; + let current = ''; + let paren_depth = 0; + + for (const char of class_name) { + if (char === '(') { + paren_depth++; + current += char; + } else if (char === ')') { + paren_depth--; + current += char; + } else if (char === ':' && paren_depth === 0) { + if (current) { + segments.push(current); + current = ''; + } + } else { + current += char; + } + } + + if (current) { + segments.push(current); + } + + return segments; +}; + +/** + * Extracts and validates modifiers from the beginning of a segments array. + * Modifiers are consumed from the front until a non-modifier segment is found. + * + * Used by both CSS-literal parsing and modified class interpretation. + * + * @param segments - Array of colon-separated segments + * @param class_name - Original class name for error messages + * @returns ModifierExtractionResult with modifiers and remaining segments, or error + */ +export const extract_and_validate_modifiers = ( + segments: Array, + class_name: string, +): ModifierExtractionResult => { + let media: ModifierDefinition | null = null; + let ancestor: ModifierDefinition | null = null; + const states: Array = []; + let pseudo_element: ModifierDefinition | null = null; + + let i = 0; + for (; i < segments.length; i++) { + const segment = segments[i]!; + const modifier = get_modifier(segment); + + // If not a modifier, stop - remaining segments are the base class/property:value + if (!modifier) { + break; + } + + // Validate order based on modifier type + switch (modifier.type) { + case 'media': { + if (media) { + return { + ok: false, + error: { + level: 'error', + message: `Multiple media modifiers not allowed`, + class_name, + suggestion: null, + }, + }; + } + if (ancestor) { + return { + ok: false, + error: { + level: 'error', + message: `Media modifier must come before ancestor modifier`, + class_name, + suggestion: `Move "${segment}" before "${ancestor.name}"`, + }, + }; + } + if (states.length > 0) { + return { + ok: false, + error: { + level: 'error', + message: `Media modifier must come before state modifiers`, + class_name, + suggestion: `Move "${segment}" before "${states[0]!.name}"`, + }, + }; + } + if (pseudo_element) { + return { + ok: false, + error: { + level: 'error', + message: `Media modifier must come before pseudo-element`, + class_name, + suggestion: `Move "${segment}" before "${pseudo_element.name}"`, + }, + }; + } + media = modifier; + break; + } + + case 'ancestor': { + if (ancestor) { + return { + ok: false, + error: { + level: 'error', + message: `Modifiers "${ancestor.name}" and "${segment}" are mutually exclusive`, + class_name, + suggestion: null, + }, + }; + } + if (states.length > 0) { + return { + ok: false, + error: { + level: 'error', + message: `Ancestor modifier must come before state modifiers`, + class_name, + suggestion: `Move "${segment}" before "${states[0]!.name}"`, + }, + }; + } + if (pseudo_element) { + return { + ok: false, + error: { + level: 'error', + message: `Ancestor modifier must come before pseudo-element`, + class_name, + suggestion: `Move "${segment}" before "${pseudo_element.name}"`, + }, + }; + } + ancestor = modifier; + break; + } + + case 'state': { + if (pseudo_element) { + return { + ok: false, + error: { + level: 'error', + message: `State modifiers must come before pseudo-element`, + class_name, + suggestion: `Move "${segment}" before "${pseudo_element.name}"`, + }, + }; + } + // Check alphabetical order (full string comparison) + if (states.length > 0) { + const prev = states[states.length - 1]!; + if (segment < prev.name) { + return { + ok: false, + error: { + level: 'error', + message: `State modifiers must be in alphabetical order: "${prev.name}:${segment}" should be "${segment}:${prev.name}"`, + class_name, + suggestion: `Reorder to: ...${segment}:${prev.name}:...`, + }, + }; + } + } + states.push(modifier); + break; + } + + case 'pseudo-element': { + if (pseudo_element) { + return { + ok: false, + error: { + level: 'error', + message: `Multiple pseudo-element modifiers not allowed`, + class_name, + suggestion: null, + }, + }; + } + pseudo_element = modifier; + break; + } + } + } + + return { + ok: true, + modifiers: {media, ancestor, states, pseudo_element}, + remaining: segments.slice(i), + }; +}; + +/** + * Parses a CSS-literal class name into its components. + * + * @param class_name - The class name to parse + * @param css_properties - Set of valid CSS properties from `load_css_properties()`. + * Pass `null` to skip property validation. + * @returns CssLiteralParseResult with parsed data or error + */ +export const parse_css_literal = ( + class_name: string, + css_properties: Set | null, +): CssLiteralParseResult => { + const segments = extract_segments(class_name); + + if (segments.length < 2) { + return { + ok: false, + error: { + level: 'error', + message: `Invalid CSS-literal syntax: expected "property:value" format`, + class_name, + suggestion: null, + }, + }; + } + + // Work backwards from end to find property:value + // Everything before that is modifiers + const value = segments[segments.length - 1]!; + const property = segments[segments.length - 2]!; + const modifier_segments = segments.slice(0, -2); + + const diagnostics: Array = []; + + // Validate modifiers using shared validation logic + const modifier_result = extract_and_validate_modifiers(modifier_segments, class_name); + + if (!modifier_result.ok) { + return {ok: false, error: modifier_result.error}; + } + + // All segments should have been consumed as modifiers + // If any remain, they're unknown modifiers (since we already separated property:value) + if (modifier_result.remaining.length > 0) { + const unknown = modifier_result.remaining[0]!; + const suggestion = suggest_modifier(unknown); + return { + ok: false, + error: { + level: 'error', + message: `Unknown modifier "${unknown}"`, + class_name, + suggestion: suggestion ? `Did you mean "${suggestion}"?` : null, + }, + }; + } + + const {media, ancestor, states, pseudo_element} = modifier_result.modifiers; + + // Validate property + if (!is_valid_css_property(property, css_properties)) { + const suggestion = suggest_css_property(property, css_properties); + return { + ok: false, + error: { + level: 'error', + message: `Unknown CSS property "${property}"`, + class_name, + suggestion: suggestion ? `Did you mean "${suggestion}"?` : null, + }, + }; + } + + // Format value + const formatted_value = format_css_value(value); + + // Check for calc warnings + const calc_warning = check_calc_expression(formatted_value); + if (calc_warning) { + diagnostics.push({ + level: 'warning', + message: calc_warning, + class_name, + suggestion: `Use ~ for spaces in calc expressions`, + }); + } + + return { + ok: true, + parsed: { + class_name, + media, + ancestor, + states, + pseudo_element, + property, + value: formatted_value, + }, + diagnostics, + }; +}; + +// +// CSS Generation +// + +/** + * Generates the CSS selector for a parsed CSS-literal class. + * Includes state pseudo-classes and pseudo-element in the selector. + */ +export const generate_selector = (escaped_class_name: string, parsed: ParsedCssLiteral): string => { + let selector = `.${escaped_class_name}`; + + // Add state pseudo-classes + for (const state of parsed.states) { + selector += state.css; + } + + // Add pseudo-element (must come last) + if (parsed.pseudo_element) { + selector += parsed.pseudo_element.css; + } + + return selector; +}; + +/** + * Generates the CSS declaration for a parsed CSS-literal class. + */ +export const generate_declaration = (parsed: ParsedCssLiteral): string => { + return `${parsed.property}: ${parsed.value};`; +}; + +/** + * Information needed to generate CSS output for a CSS-literal class. + */ +export interface CssLiteralOutput { + /** CSS declaration (property: value;) */ + declaration: string; + /** Full CSS selector including pseudo-classes/elements */ + selector: string; + /** Media query wrapper if any */ + media_wrapper: string | null; + /** Ancestor wrapper if any */ + ancestor_wrapper: string | null; +} + +/** + * Interprets a CSS-literal class and returns CSS generation info. + * + * Callers should first check `is_possible_css_literal()` to filter non-CSS-literal classes. + * + * @param class_name - The class name to interpret + * @param escaped_class_name - The CSS-escaped version of the class name + * @param css_properties - Set of valid CSS properties from `load_css_properties()`. + * Pass `null` to skip property validation. + * @returns Result with output and warnings on success, or error on failure + */ +export const interpret_css_literal = ( + class_name: string, + escaped_class_name: string, + css_properties: Set | null, +): InterpretCssLiteralResult => { + const result = parse_css_literal(class_name, css_properties); + + if (!result.ok) { + return {ok: false, error: result.error}; + } + + const {parsed, diagnostics} = result; + + return { + ok: true, + output: { + declaration: generate_declaration(parsed), + selector: generate_selector(escaped_class_name, parsed), + media_wrapper: parsed.media?.css ?? null, + ancestor_wrapper: parsed.ancestor?.css ?? null, + }, + warnings: diagnostics, + }; +}; + +/** + * Generates simple CSS for a CSS-literal class (without grouping). + * Used by the interpreter for basic output. + * + * @param output - The CSS-literal output info + * @returns CSS string for this class + */ +export const generate_css_literal_simple = (output: CssLiteralOutput): string => { + let css = ''; + let indent = ''; + + // Open media wrapper if present + if (output.media_wrapper) { + css += `${output.media_wrapper} {\n`; + indent = '\t'; + } + + // Open ancestor wrapper if present + if (output.ancestor_wrapper) { + css += `${indent}${output.ancestor_wrapper} {\n`; + indent += '\t'; + } + + // Write the rule + css += `${indent}${output.selector} { ${output.declaration} }\n`; + + // Close ancestor wrapper + if (output.ancestor_wrapper) { + indent = indent.slice(0, -1); + css += `${indent}}\n`; + } + + // Close media wrapper + if (output.media_wrapper) { + css += '}\n'; + } + + return css; +}; diff --git a/src/lib/css_ruleset_parser.ts b/src/lib/css_ruleset_parser.ts new file mode 100644 index 000000000..6eb802cd1 --- /dev/null +++ b/src/lib/css_ruleset_parser.ts @@ -0,0 +1,537 @@ +/** + * CSS ruleset parser using Svelte's CSS parser. + * + * Parses CSS rulesets to extract selectors and declarations with position information. + * Used for: + * - Phase 0a: Detecting single-selector rulesets that could be converted to declaration format + * - Phase 2: Modifying selectors for modifier support on composite classes + * + * NOTE: We wrap CSS in a `'; + +/** + * Parses a CSS ruleset string using Svelte's CSS parser. + * + * @param css - Raw CSS string (e.g., ".box { display: flex; }") + * @returns ParsedRuleset with structured rule data and positions + */ +export const parse_ruleset = (css: string): ParsedRuleset => { + const wrapper_offset = STYLE_WRAPPER_PREFIX.length; + const wrapped = `${STYLE_WRAPPER_PREFIX}${css}${STYLE_WRAPPER_SUFFIX}`; + + const ast = parse(wrapped, {modern: true}); + const rules: Array = []; + + if (!ast.css) { + return {rules, wrapper_offset}; + } + + // Walk the CSS AST to find rules + walk_css_children(ast.css, css, wrapper_offset, rules); + + return {rules, wrapper_offset}; +}; + +/** + * Walks CSS AST children to extract rules. + */ +const walk_css_children = ( + node: AST.CSS.StyleSheet | AST.CSS.Atrule, + original_css: string, + wrapper_offset: number, + rules: Array, +): void => { + const children = 'children' in node ? node.children : []; + + for (const child of children) { + if (child.type === 'Rule') { + extract_rule(child, original_css, wrapper_offset, rules); + } else if ('children' in child) { + // Recurse into at-rules (like @media) + walk_css_children(child, original_css, wrapper_offset, rules); + } + } +}; + +/** + * Extracts a single rule from the AST. + */ +const extract_rule = ( + rule: AST.CSS.Rule, + original_css: string, + wrapper_offset: number, + rules: Array, +): void => { + const prelude = rule.prelude; + + // Get the full selector text from the original CSS + const selector_start = prelude.start - wrapper_offset; + const selector_end = prelude.end - wrapper_offset; + const selector = original_css.slice(selector_start, selector_end); + + // Get declarations from the block + const block = rule.block; + const block_start = block.start - wrapper_offset; + const block_end = block.end - wrapper_offset; + + // Extract just the declarations (content between braces) + const block_content = original_css.slice(block_start, block_end); + const declarations = block_content.slice(1, -1).trim(); // Remove { } and trim + + rules.push({ + selector, + selector_start, + selector_end, + declarations, + rule_start: rule.start - wrapper_offset, + rule_end: rule.end - wrapper_offset, + }); +}; + +/** + * Checks if a ruleset has only a single simple selector (just the class name). + * Used to detect rulesets that could be converted to declaration format. + * + * @param rules - Parsed rules from the ruleset + * @param expected_class_name - The class name we expect (e.g., "box") + * @returns True if there's exactly one rule with selector ".class_name" + */ +export const is_single_selector_ruleset = ( + rules: Array, + expected_class_name: string, +): boolean => { + if (rules.length !== 1) return false; + + const rule = rules[0]!; + const expected_selector = `.${expected_class_name}`; + + // Normalize whitespace in selector for comparison + const normalized_selector = rule.selector.trim(); + + return normalized_selector === expected_selector; +}; + +/** + * Extracts the CSS comment from a ruleset (if any). + * Looks for comments before the first rule. + * + * @param css - Raw CSS string + * @param rules - Parsed rules + * @returns Comment text without delimiters, or null if no comment + */ +export const extract_css_comment = (css: string, rules: Array): string | null => { + if (rules.length === 0) return null; + + const first_rule_start = rules[0]!.rule_start; + const before_rule = css.slice(0, first_rule_start).trim(); + + // Check for /* */ comment + const comment_pattern = /\/\*\s*([\s\S]*?)\s*\*\//; + const comment_match = comment_pattern.exec(before_rule); + if (comment_match) { + return comment_match[1]!.trim(); + } + + return null; +}; + +// +// Selector Modification +// + +/** + * Information about a modifier that was skipped for a selector during ruleset modification. + * The selector is still included in output, just without the conflicting modifier applied. + */ +export interface SkippedModifierInfo { + /** The specific selector where the modifier was skipped (not the full selector list) */ + selector: string; + /** Reason the modifier was skipped */ + reason: 'pseudo_element_conflict' | 'state_conflict'; + /** The conflicting modifier that was not applied (e.g., "::before" or ":hover") */ + conflicting_modifier: string; +} + +/** + * Result from modifying a selector group with conflict detection. + */ +export interface ModifiedSelectorGroupResult { + /** The modified selector list as a string */ + selector: string; + /** Information about modifiers skipped for specific selectors */ + skipped_modifiers: Array; +} + +/** + * Result from generating a modified ruleset. + */ +export interface ModifiedRulesetResult { + /** The generated CSS */ + css: string; + /** Information about modifiers that were skipped for certain rules */ + skipped_modifiers: Array; +} + +/** + * Skips an identifier (class name, pseudo-class name, etc.) starting at `start`. + * + * @returns New position after the identifier + */ +const skip_identifier = (selector: string, start: number): number => { + let pos = start; + while (pos < selector.length && /[\w-]/.test(selector[pos]!)) { + pos++; + } + return pos; +}; + +/** + * Checks if a selector contains a pseudo-element (::before, ::after, etc.). + */ +const selector_has_pseudo_element = (selector: string): boolean => { + return selector.includes('::'); +}; + +/** + * Checks if a selector already contains a specific state pseudo-class. + * Handles functional pseudo-classes like :not(:hover) by checking for the state + * both as a direct pseudo-class and within functional pseudo-classes. + * + * @param selector - The CSS selector to check + * @param state - The state to look for (e.g., ":hover", ":focus") + * @returns True if the selector already contains this state + */ +const selector_has_state = (selector: string, state: string): boolean => { + // Simple check: does the selector contain the state string? + // This catches both direct usage (.foo:hover) and within functional pseudo-classes (.foo:not(:hover)) + return selector.includes(state); +}; + +/** + * Splits a selector list by commas, respecting parentheses. + * + * @example + * split_selector_list('.a, .b') → ['.a', '.b'] + * split_selector_list('.a:not(.b), .c') → ['.a:not(.b)', '.c'] + */ +export const split_selector_list = (selector_group: string): Array => { + const selectors: Array = []; + let current = ''; + let paren_depth = 0; + + for (const char of selector_group) { + if (char === '(') { + paren_depth++; + current += char; + } else if (char === ')') { + paren_depth--; + current += char; + } else if (char === ',' && paren_depth === 0) { + selectors.push(current.trim()); + current = ''; + } else { + current += char; + } + } + + if (current.trim()) { + selectors.push(current.trim()); + } + + return selectors; +}; + +/** + * Finds the end position of the compound selector containing the class at class_pos. + * A compound selector is a sequence of simple selectors without combinators. + * + * @param selector - The CSS selector string + * @param class_pos - Position of the `.` in `.class_name` + * @returns Position where state modifiers should be inserted (before any pseudo-element) + */ +export const find_compound_end = (selector: string, class_pos: number): number => { + let pos = class_pos + 1; // Skip the dot + pos = skip_identifier(selector, pos); // Skip the class name + + // Continue scanning while we see parts of the same compound selector + while (pos < selector.length) { + const char = selector[pos]!; + + if (char === '.') { + // Another class - skip it + pos++; + pos = skip_identifier(selector, pos); + } else if (char === '[') { + // Attribute selector - find matching ] + while (pos < selector.length && selector[pos] !== ']') { + pos++; + } + if (pos < selector.length) pos++; // Skip ] + } else if (char === ':') { + // Pseudo-class or pseudo-element + if (selector[pos + 1] === ':') { + // Pseudo-element - stop here (state comes before pseudo-element) + break; + } + // Pseudo-class - skip it (including functional ones like :not()) + pos++; // Skip : + pos = skip_identifier(selector, pos); + if (pos < selector.length && selector[pos] === '(') { + // Functional pseudo-class - find matching ) + let paren_depth = 1; + pos++; + while (pos < selector.length && paren_depth > 0) { + if (selector[pos] === '(') paren_depth++; + else if (selector[pos] === ')') paren_depth--; + pos++; + } + } + } else { + // Hit whitespace, combinator, or something else - compound ends here + break; + } + } + + return pos; +}; + +/** + * Modifies a single CSS selector to add modifiers. + * + * @param selector - A single CSS selector (not a selector list) + * @param original_class - The base class name (e.g., "menu_item") + * @param new_class_escaped - The escaped new class name (e.g., "hover\\:menu_item") + * @param state_css - State modifier CSS to insert (e.g., ":hover") + * @param pseudo_element_css - Pseudo-element modifier CSS to insert (e.g., "::before") + * @returns Modified selector + * + * @example + * modify_single_selector('.menu_item', 'menu_item', 'hover\\:menu_item', ':hover', '') + * // → '.hover\\:menu_item:hover' + * + * modify_single_selector('.menu_item .icon', 'menu_item', 'hover\\:menu_item', ':hover', '') + * // → '.hover\\:menu_item:hover .icon' + * + * modify_single_selector('.menu_item.selected', 'menu_item', 'hover\\:menu_item', ':hover', '') + * // → '.hover\\:menu_item.selected:hover' + */ +export const modify_single_selector = ( + selector: string, + original_class: string, + new_class_escaped: string, + state_css: string, + pseudo_element_css: string, +): string => { + // Find the target class (must match the class name exactly, not as part of another class) + const class_pattern = new RegExp(`\\.${escape_regexp(original_class)}(?![\\w-])`); + const match = class_pattern.exec(selector); + if (!match) return selector; // Class not in this selector + + const class_pos = match.index; + + // Find where the compound selector ends (where to insert state) + const compound_end = find_compound_end(selector, class_pos); + + // Build the modified selector + // Insert state and pseudo-element at compound_end (before any existing pseudo-element) + const suffix = state_css + pseudo_element_css; + let result = selector.slice(0, compound_end) + suffix + selector.slice(compound_end); + + // Replace the class name with the new escaped name + result = result.replace(class_pattern, `.${new_class_escaped}`); + + return result; +}; + +/** + * Modifies a selector list (comma-separated selectors) to add modifiers. + * Handles conflicts per-selector: if one selector in a list has a conflict, + * only that selector skips the modifier; other selectors still get it. + * + * @param selector_group - CSS selector list (may contain commas) + * @param original_class - The base class name + * @param new_class_escaped - The escaped new class name + * @param states_to_add - Individual state modifiers (e.g., [":hover", ":focus"]) + * @param pseudo_element_css - Pseudo-element modifier CSS to insert (e.g., "::before") + * @returns Result with modified selector list and information about skipped modifiers + */ +export const modify_selector_group = ( + selector_group: string, + original_class: string, + new_class_escaped: string, + states_to_add: Array, + pseudo_element_css: string, +): ModifiedSelectorGroupResult => { + const selectors = split_selector_list(selector_group); + const skipped_modifiers: Array = []; + const adding_pseudo_element = pseudo_element_css !== ''; + + const modified_selectors = selectors.map((selector) => { + const trimmed = selector.trim(); + + // Check pseudo-element conflict for this specific selector + const has_pseudo_element_conflict = + adding_pseudo_element && selector_has_pseudo_element(trimmed); + + // Check state conflicts for this specific selector - filter to non-conflicting states + const non_conflicting_states: Array = []; + for (const state of states_to_add) { + if (selector_has_state(trimmed, state)) { + // Record the skip for this specific selector + skipped_modifiers.push({ + selector: trimmed, + reason: 'state_conflict', + conflicting_modifier: state, + }); + } else { + non_conflicting_states.push(state); + } + } + + // Record pseudo-element skip for this specific selector + if (has_pseudo_element_conflict) { + skipped_modifiers.push({ + selector: trimmed, + reason: 'pseudo_element_conflict', + conflicting_modifier: pseudo_element_css, + }); + } + + // Build effective modifiers for this selector + const effective_state_css = non_conflicting_states.join(''); + const effective_pseudo_element_css = has_pseudo_element_conflict ? '' : pseudo_element_css; + + return modify_single_selector( + trimmed, + original_class, + new_class_escaped, + effective_state_css, + effective_pseudo_element_css, + ); + }); + + return { + selector: modified_selectors.join(',\n'), + skipped_modifiers, + }; +}; + +/** + * Generates CSS for a modified ruleset with applied modifiers. + * + * Conflict handling is per-selector within selector lists: + * - For `.plain:hover, .plain:active` with `hover:` modifier, only `.plain:hover` skips + * the `:hover` addition; `.plain:active` still gets `:hover` appended. + * - For multiple states like `:hover:focus`, each is checked individually; conflicting + * states are skipped while non-conflicting ones are still applied. + * + * @param original_ruleset - The original CSS ruleset string + * @param original_class - The base class name + * @param new_class_escaped - The escaped new class name with modifiers + * @param state_css - State modifier CSS (e.g., ":hover" or ":hover:focus") + * @param pseudo_element_css - Pseudo-element modifier CSS (e.g., "::before") + * @param media_wrapper - Media query wrapper (e.g., "@media (width >= 48rem)") + * @param ancestor_wrapper - Ancestor wrapper (e.g., ":root.dark") + * @returns Result with generated CSS and information about skipped modifiers + */ +export const generate_modified_ruleset = ( + original_ruleset: string, + original_class: string, + new_class_escaped: string, + state_css: string, + pseudo_element_css: string, + media_wrapper: string | null, + ancestor_wrapper: string | null, +): ModifiedRulesetResult => { + const parsed = parse_ruleset(original_ruleset); + const skipped_modifiers: Array = []; + + // Extract individual states for per-selector conflict detection (e.g., ":hover:focus" → [":hover", ":focus"]) + const states_to_add = state_css.match(/:[a-z-]+/g) ?? []; + + let css = ''; + let indent = ''; + + // Open media wrapper if present + if (media_wrapper) { + css += `${media_wrapper} {\n`; + indent = '\t'; + } + + // Open ancestor wrapper if present + if (ancestor_wrapper) { + css += `${indent}${ancestor_wrapper} {\n`; + indent += '\t'; + } + + // Generate each rule with modified selector (conflict detection happens per-selector in modify_selector_group) + for (const rule of parsed.rules) { + const result = modify_selector_group( + rule.selector, + original_class, + new_class_escaped, + states_to_add, + pseudo_element_css, + ); + + // Collect skip info from per-selector conflict detection + skipped_modifiers.push(...result.skipped_modifiers); + + css += `${indent}${result.selector} { ${rule.declarations} }\n`; + } + + // Close ancestor wrapper + if (ancestor_wrapper) { + indent = indent.slice(0, -1); + css += `${indent}}\n`; + } + + // Close media wrapper + if (media_wrapper) { + css += '}\n'; + } + + return {css, skipped_modifiers}; +}; diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts new file mode 100644 index 000000000..ac63c740a --- /dev/null +++ b/src/lib/diagnostics.ts @@ -0,0 +1,96 @@ +/** + * Diagnostic types for CSS class extraction and generation. + * + * Provides a unified diagnostic system across all phases: + * - Extraction: Parsing source files to find class names + * - Generation: Producing CSS output from class definitions + * + * @module + */ + +// +// Source Location +// + +/** + * Source location for IDE/LSP integration. + */ +export interface SourceLocation { + file: string; + /** 1-based line number */ + line: number; + /** 1-based column number */ + column: number; +} + +// +// Diagnostic Types +// + +/** + * Base diagnostic with common fields. + */ +export interface BaseDiagnostic { + level: 'error' | 'warning'; + message: string; + suggestion: string | null; +} + +/** + * Diagnostic from the extraction phase. + */ +export interface ExtractionDiagnostic extends BaseDiagnostic { + phase: 'extraction'; + location: SourceLocation; +} + +/** + * Diagnostic from the generation phase. + */ +export interface GenerationDiagnostic { + phase: 'generation'; + level: 'error' | 'warning'; + message: string; + suggestion: string | null; + class_name: string; + /** Source locations where this class was used, or null if from include_classes */ + locations: Array | null; +} + +/** + * Union of all diagnostic types. + */ +export type Diagnostic = ExtractionDiagnostic | GenerationDiagnostic; + +/** + * Diagnostic from CSS class interpretation. + * Used internally by interpreters; converted to GenerationDiagnostic with locations. + */ +export interface CssClassDiagnostic { + level: 'error' | 'warning'; + message: string; + class_name: string; + suggestion: string | null; +} + +// +// Diagnostic Utilities +// + +/** + * Converts a CssClassDiagnostic to a GenerationDiagnostic with locations. + * + * @param diagnostic - Interpreter diagnostic to convert + * @param locations - Source locations where the class was used + */ +export const create_generation_diagnostic = ( + diagnostic: CssClassDiagnostic, + locations: Array | null, +): GenerationDiagnostic => ({ + phase: 'generation', + level: diagnostic.level, + message: diagnostic.message, + class_name: diagnostic.class_name, + suggestion: diagnostic.suggestion ?? null, + locations, +}); diff --git a/src/lib/gen_fuz_css.ts b/src/lib/gen_fuz_css.ts index 5033e2dcb..c3390a0fc 100644 --- a/src/lib/gen_fuz_css.ts +++ b/src/lib/gen_fuz_css.ts @@ -1,57 +1,233 @@ +/** + * Gro generator for creating optimized utility CSS from extracted class names. + * Scans source files, extracts CSS classes with AST-based parsing, and generates + * only the CSS for classes actually used. Includes per-file caching with content + * hash validation for fast incremental rebuilds. + * + * @module + */ + +import {join} from 'node:path'; import type {Gen} from '@ryanatkn/gro/gen.js'; import type {FileFilter} from '@fuzdev/fuz_util/path.js'; +import {map_concurrent, each_concurrent} from '@fuzdev/fuz_util/async.js'; +import {extract_css_classes_with_locations, type AcornPlugin} from './css_class_extractor.js'; +import {type SourceLocation, type ExtractionDiagnostic, type Diagnostic} from './diagnostics.js'; +import {CssClasses} from './css_classes.js'; import { - collect_css_classes, - CssClasses, generate_classes_css, - type CssClassDeclaration, - type CssClassDeclarationInterpreter, -} from './css_class_helpers.js'; -import {css_classes_by_name} from './css_classes.js'; + type CssClassDefinition, + type CssClassDefinitionInterpreter, +} from './css_class_generation.js'; +import {css_class_definitions} from './css_class_definitions.js'; +import {css_class_composites} from './css_class_composites.js'; import {css_class_interpreters} from './css_class_interpreters.js'; +import {load_css_properties} from './css_literal.js'; +import { + get_cache_path, + load_cached_extraction, + save_cached_extraction, + delete_cached_extraction, + from_cached_extraction, +} from './css_cache.js'; + +/** + * Skip cache on CI (no point writing cache that won't be reused). + * Handles CI=1, CI=true, and other truthy values. + */ +const is_ci = !!process.env.CI; + +/** + * Default concurrency for main loop: cache read + extract. + * This is NOT true CPU parallelism - Node.js JS is single-threaded. + * The value controls I/O interleaving (overlapping cache reads with parsing) + * and memory budget for in-flight operations. Higher values offer diminishing + * returns since AST parsing is synchronous on the main thread. + */ +const DEFAULT_CONCURRENCY = 8; + +/** + * Default concurrency for cache writes/deletes (I/O-bound). + * Safe to set high since Node's libuv thread pool (default 4 threads) + * limits actual parallel I/O operations. Memory pressure from buffered + * writes is the main constraint, but cache entries are small JSON files. + */ +const DEFAULT_CACHE_IO_CONCURRENCY = 50; + +/** + * Result from extracting CSS classes from a single file. + * Used internally during parallel extraction with caching. + * Uses `null` instead of empty collections to avoid allocation overhead. + */ +interface FileExtraction { + id: string; + /** Extracted classes, or null if none */ + classes: Map> | null; + /** Extraction diagnostics, or null if none */ + diagnostics: Array | null; + /** Cache path to write to, or null if no write needed (cache hit or CI) */ + cache_path: string | null; + content_hash: string; +} export interface GenFuzCssOptions { filter_file?: FileFilter | null; include_stats?: boolean; - classes_by_name?: Record; - class_interpreters?: Array; + class_definitions?: Record; + class_interpreters?: Array; + /** + * How to handle CSS-literal errors during generation. + * - 'log' (default): Log errors, skip invalid classes, continue + * - 'throw': Throw on first error, fail the build + */ + on_error?: 'log' | 'throw'; + /** + * Classes to always include in the output, regardless of whether they're detected in source files. + * Useful for dynamically generated class names that can't be statically extracted. + */ + include_classes?: Iterable; + /** + * Classes to exclude from the output, even if they're detected in source files. + * Useful for filtering out false positives from extraction. + */ + exclude_classes?: Iterable; + /** + * Cache directory relative to project_root. + * @default '.fuz/cache/css' + */ + cache_dir?: string; + /** + * Project root directory. Source paths must be under this directory. + * @default process.cwd() + */ + project_root?: string; + /** + * Max concurrent file processing (cache read + extract). + * Bottlenecked by CPU-bound AST parsing. + * @default 8 + */ + concurrency?: number; + /** + * Max concurrent cache writes and deletes (I/O-bound). + * @default 20 + */ + cache_io_concurrency?: number; + /** + * Additional acorn plugins to use when parsing TS/JS files. + * Useful for adding JSX support via `acorn-jsx` for React projects. + * + * @example + * ```ts + * import jsx from 'acorn-jsx'; + * export const gen = gen_fuz_css({ + * acorn_plugins: [jsx()], + * }); + * ``` + */ + acorn_plugins?: Array; +} + +/** + * Formats a diagnostic for display. + */ +const format_diagnostic = (d: Diagnostic): string => { + const suggestion = d.suggestion ? ` (${d.suggestion})` : ''; + if (d.phase === 'extraction') { + return ` - ${d.location.file}:${d.location.line}:${d.location.column}: ${d.message}${suggestion}`; + } + const loc = d.locations?.[0]; + const location_str = loc ? `${loc.file}:${loc.line}:${loc.column}: ` : ''; + return ` - ${location_str}${d.class_name}: ${d.message}${suggestion}`; +}; + +/** + * Error thrown when CSS-literal generation encounters errors and `on_error: 'throw'` is set. + */ +export class CssGenerationError extends Error { + diagnostics: Array; + + constructor(diagnostics: Array) { + const error_count = diagnostics.filter((d) => d.level === 'error').length; + const message = `CSS generation failed with ${error_count} error${error_count === 1 ? '' : 's'}:\n${diagnostics + .filter((d) => d.level === 'error') + .map(format_diagnostic) + .join('\n')}`; + super(message); + this.name = 'CssGenerationError'; + this.diagnostics = diagnostics; + } } const filter_file_default: FileFilter = (path) => { - if (path.includes('.test.') || path.includes('/test/') || path.includes('.gen.')) { + if ( + path.includes('.test.') || + path.includes('/test/') || + path.includes('/tests/') || + path.includes('.gen.') + ) { return false; } const ext = path.slice(path.lastIndexOf('.')); - return ext === '.svelte' || ext === '.ts' || ext === '.js'; + return ( + ext === '.svelte' || + ext === '.html' || + ext === '.ts' || + ext === '.js' || + ext === '.tsx' || + ext === '.jsx' + ); }; export const gen_fuz_css = (options: GenFuzCssOptions = {}): Gen => { const { filter_file = filter_file_default, include_stats = false, - classes_by_name = css_classes_by_name, + class_definitions = css_class_definitions, class_interpreters = css_class_interpreters, + on_error = 'log', + include_classes, + exclude_classes, + cache_dir = '.fuz/cache/css', + project_root: project_root_option, + concurrency = DEFAULT_CONCURRENCY, + cache_io_concurrency = DEFAULT_CACHE_IO_CONCURRENCY, + acorn_plugins, } = options; + // Convert to Sets for efficient lookup + const include_set = include_classes ? new Set(include_classes) : null; + const exclude_set = exclude_classes ? new Set(exclude_classes) : null; + + // Instance-level state for watch mode cleanup + let previous_paths: Set | null = null; + return { - dependencies: 'all', - // TODO optimize, do we need to handle deleted files or removed classes though? - // This isn't as much a problem in watch mode but isn't clean. - // dependencies: ({changed_file_id, filer}) => { - // if (!changed_file_id) return 'all'; - // const disknode = filer.get_by_id(changed_file_id); - // if (disknode?.contents && collect_css_classes(disknode.contents).size) { - // return 'all'; - // } - // return null; - // }, + // Filter dependencies to skip non-extractable files. + // Returns 'all' when an extractable file changes, null otherwise. + dependencies: ({changed_file_id}) => { + if (!changed_file_id) return 'all'; + if (!filter_file || filter_file(changed_file_id)) return 'all'; + return null; // Ignore .json, .md, etc. + }, + generate: async ({filer, log, origin_path}) => { log.info('generating Fuz CSS classes...'); + // Load CSS properties for validation before generation + const css_properties = await load_css_properties(); + await filer.init(); - const css_classes = new CssClasses(); + // Normalize project root - ensure it ends with / + const raw_project_root = project_root_option ?? process.cwd(); + const project_root = raw_project_root.endsWith('/') + ? raw_project_root + : raw_project_root + '/'; + const resolved_cache_dir = join(project_root, cache_dir); + + const css_classes = new CssClasses(include_set); + const current_paths: Set = new Set(); const stats = { total_files: filer.files.size, @@ -60,8 +236,17 @@ export const gen_fuz_css = (options: GenFuzCssOptions = {}): Gen => { processed_files: 0, files_with_content: 0, files_with_classes: 0, + cache_hits: 0, + cache_misses: 0, }; + // Collect nodes to process + const nodes: Array<{ + id: string; + contents: string; + content_hash: string; + }> = []; + for (const disknode of filer.files.values()) { if (disknode.external) { stats.external_files++; @@ -75,21 +260,116 @@ export const gen_fuz_css = (options: GenFuzCssOptions = {}): Gen => { stats.processed_files++; - if (disknode.contents !== null) { + if (disknode.contents !== null && disknode.content_hash !== null) { stats.files_with_content++; - const classes = collect_css_classes(disknode.contents); - if (classes.size > 0) { - css_classes.add(disknode.id, classes); + nodes.push({ + id: disknode.id, + contents: disknode.contents, + content_hash: disknode.content_hash, + }); + } + } + + // Parallel extraction with cache check + const extractions: Array = await map_concurrent( + nodes, + async (node): Promise => { + current_paths.add(node.id); + const cache_path = get_cache_path(node.id, resolved_cache_dir, project_root); + + // Try cache (skip on CI) + if (!is_ci) { + const cached = await load_cached_extraction(cache_path); + if (cached && cached.content_hash === node.content_hash) { + // Cache hit + stats.cache_hits++; + return { + id: node.id, + ...from_cached_extraction(cached), + cache_path: null, + content_hash: node.content_hash, + }; + } + } + + // Cache miss - extract + stats.cache_misses++; + const result = extract_css_classes_with_locations(node.contents, { + filename: node.id, + acorn_plugins, + }); + + return { + id: node.id, + classes: result.classes, + diagnostics: result.diagnostics, + cache_path: is_ci ? null : cache_path, + content_hash: node.content_hash, + }; + }, + concurrency, + ); + + // Add to CssClasses (null = empty, so use truthiness check) + for (const {id, classes, diagnostics} of extractions) { + if (classes || diagnostics) { + css_classes.add(id, classes, diagnostics); + if (classes) { stats.files_with_classes++; } } } - const unique_classes = new Set(); - for (const file_classes of css_classes.get().values()) { - for (const class_name of file_classes) { - unique_classes.add(class_name); + // Collect cache writes (entries that need writing) + const cache_writes = extractions.filter( + (e): e is FileExtraction & {cache_path: string} => e.cache_path !== null, + ); + + // Parallel cache writes (await completion) + if (cache_writes.length > 0) { + await each_concurrent( + cache_writes, + async ({cache_path, content_hash, classes, diagnostics}) => { + await save_cached_extraction(cache_path, content_hash, classes, diagnostics); + }, + cache_io_concurrency, + ).catch((err) => log.warn('Cache write error:', err)); + } + + // Watch mode cleanup: delete cache files for removed source files + // Note: Empty directories are intentionally left behind (rare case, not worth the cost) + if (!is_ci && previous_paths) { + const paths_to_delete = [...previous_paths].filter((p) => !current_paths.has(p)); + if (paths_to_delete.length > 0) { + await each_concurrent( + paths_to_delete, + async (path) => { + const cache_path = get_cache_path(path, resolved_cache_dir, project_root); + await delete_cached_extraction(cache_path); + }, + cache_io_concurrency, + ).catch(() => { + // Ignore deletion errors + }); + } + } + previous_paths = current_paths; + + // Get all classes with locations (single recalculation) + let {all_classes, all_classes_with_locations} = css_classes.get_all(); + + // Apply exclude filter if configured + if (exclude_set) { + const filtered: Set = new Set(); + const filtered_with_locations: Map | null> = new Map(); + for (const cls of all_classes) { + if (!exclude_set.has(cls)) { + filtered.add(cls); + filtered_with_locations.set(cls, all_classes_with_locations.get(cls) ?? null); + } } + all_classes = filtered; + all_classes_with_locations = filtered_with_locations; } if (include_stats) { @@ -100,29 +380,67 @@ export const gen_fuz_css = (options: GenFuzCssOptions = {}): Gen => { log.info(` Files processed (passed filter): ${stats.processed_files}`); log.info(` With content: ${stats.files_with_content}`); log.info(` With CSS classes: ${stats.files_with_classes}`); - log.info(` Unique CSS classes found: ${unique_classes.size}`); + log.info(` Cache: ${stats.cache_hits} hits, ${stats.cache_misses} misses`); + log.info(` Unique CSS classes found: ${all_classes.size}`); } - const css = generate_classes_css(css_classes.get(), classes_by_name, class_interpreters, log); + // Merge token classes with composites for interpreter access + const all_class_definitions = {...class_definitions, ...css_class_composites}; + + const result = generate_classes_css({ + class_names: all_classes, + class_definitions: all_class_definitions, + interpreters: class_interpreters, + css_properties, + log, + class_locations: all_classes_with_locations, + }); + + // Collect all diagnostics: extraction + generation + const all_diagnostics: Array = [ + ...css_classes.get_diagnostics(), + ...result.diagnostics, + ]; + + // Separate errors and warnings + const errors = all_diagnostics.filter((d) => d.level === 'error'); + const warnings = all_diagnostics.filter((d) => d.level === 'warning'); + + // Log all warnings using consistent format + for (const warning of warnings) { + log.warn(format_diagnostic(warning)); + } + + // Handle errors based on on_error setting + if (errors.length > 0) { + if (on_error === 'throw') { + throw new CssGenerationError(all_diagnostics); + } + // 'log' mode - errors are already logged by interpret_css_literal + log.warn( + `CSS generation completed with ${errors.length} error${errors.length === 1 ? '' : 's'} (invalid classes skipped)`, + ); + } const banner = `generated by ${origin_path}`; const content_parts = [`/* ${banner} */`]; if (include_stats) { - const performance_note = `/* * + const performance_note = `/* * * File statistics: * - Total files in filer: ${stats.total_files} * - External dependencies: ${stats.external_files} * - Internal project files: ${stats.internal_files} * - Files processed (passed filter): ${stats.processed_files} * - Files with CSS classes: ${stats.files_with_classes} - * - Unique classes found: ${unique_classes.size} + * - Cache: ${stats.cache_hits} hits, ${stats.cache_misses} misses + * - Unique classes found: ${all_classes.size} */`; content_parts.push(performance_note); } - content_parts.push(css); + content_parts.push(result.css); content_parts.push(`/* ${banner} */`); return content_parts.join('\n\n'); diff --git a/src/lib/modifiers.ts b/src/lib/modifiers.ts new file mode 100644 index 000000000..212a69b3f --- /dev/null +++ b/src/lib/modifiers.ts @@ -0,0 +1,262 @@ +/** + * Declarative modifier definitions for CSS-literal syntax. + * + * Modifiers enable responsive, state-based, and contextual styling: + * - Media modifiers: `md:`, `print:`, `motion-safe:` + * - Ancestor modifiers: `dark:`, `light:` + * - State modifiers: `hover:`, `focus:`, `disabled:` + * - Pseudo-element modifiers: `before:`, `after:` + * + * @see {@link https://github.com/fuzdev/fuz_css} for documentation + * @module + */ + +/** + * Type of modifier determining its position in the class name and CSS output. + * + * Order in class names: `[media]:[ancestor]:[state...]:[pseudo-element]:property:value` + */ +export type ModifierType = 'media' | 'ancestor' | 'state' | 'pseudo-element'; + +/** + * Definition for a single modifier. + */ +export interface ModifierDefinition { + /** The prefix used in class names (e.g., 'hover', 'md', 'dark') */ + name: string; + /** Type determines position in modifier order and CSS output behavior */ + type: ModifierType; + /** The CSS output - wrapper for media/ancestor, suffix for state/pseudo-element */ + css: string; + /** Optional ordering within type (for breakpoints, sorted by this value) */ + order?: number; +} + +/** + * All modifier definitions in a single declarative structure. + * Adding a new modifier requires only adding to this array. + */ +export const MODIFIERS: Array = [ + // Media modifiers - viewport breakpoints (mobile-first) + {name: 'sm', type: 'media', css: '@media (width >= 40rem)', order: 1}, + {name: 'md', type: 'media', css: '@media (width >= 48rem)', order: 2}, + {name: 'lg', type: 'media', css: '@media (width >= 64rem)', order: 3}, + {name: 'xl', type: 'media', css: '@media (width >= 80rem)', order: 4}, + {name: '2xl', type: 'media', css: '@media (width >= 96rem)', order: 5}, + + // Max-width variants (for targeting below a breakpoint) + {name: 'max-sm', type: 'media', css: '@media (width < 40rem)', order: 11}, + {name: 'max-md', type: 'media', css: '@media (width < 48rem)', order: 12}, + {name: 'max-lg', type: 'media', css: '@media (width < 64rem)', order: 13}, + {name: 'max-xl', type: 'media', css: '@media (width < 80rem)', order: 14}, + {name: 'max-2xl', type: 'media', css: '@media (width < 96rem)', order: 15}, + + // Media modifiers - feature queries + {name: 'print', type: 'media', css: '@media print'}, + {name: 'motion-safe', type: 'media', css: '@media (prefers-reduced-motion: no-preference)'}, + {name: 'motion-reduce', type: 'media', css: '@media (prefers-reduced-motion: reduce)'}, + {name: 'contrast-more', type: 'media', css: '@media (prefers-contrast: more)'}, + {name: 'contrast-less', type: 'media', css: '@media (prefers-contrast: less)'}, + {name: 'portrait', type: 'media', css: '@media (orientation: portrait)'}, + {name: 'landscape', type: 'media', css: '@media (orientation: landscape)'}, + {name: 'forced-colors', type: 'media', css: '@media (forced-colors: active)'}, + + // Ancestor modifiers - color scheme + {name: 'dark', type: 'ancestor', css: ':root.dark'}, + {name: 'light', type: 'ancestor', css: ':root.light'}, + + // State modifiers - interaction + {name: 'hover', type: 'state', css: ':hover'}, + {name: 'focus', type: 'state', css: ':focus'}, + {name: 'focus-visible', type: 'state', css: ':focus-visible'}, + {name: 'focus-within', type: 'state', css: ':focus-within'}, + {name: 'active', type: 'state', css: ':active'}, + {name: 'visited', type: 'state', css: ':visited'}, + {name: 'target', type: 'state', css: ':target'}, + + // State modifiers - form states + {name: 'disabled', type: 'state', css: ':disabled'}, + {name: 'enabled', type: 'state', css: ':enabled'}, + {name: 'checked', type: 'state', css: ':checked'}, + {name: 'indeterminate', type: 'state', css: ':indeterminate'}, + {name: 'default', type: 'state', css: ':default'}, + {name: 'required', type: 'state', css: ':required'}, + {name: 'optional', type: 'state', css: ':optional'}, + {name: 'valid', type: 'state', css: ':valid'}, + {name: 'invalid', type: 'state', css: ':invalid'}, + {name: 'user-valid', type: 'state', css: ':user-valid'}, + {name: 'user-invalid', type: 'state', css: ':user-invalid'}, + {name: 'in-range', type: 'state', css: ':in-range'}, + {name: 'out-of-range', type: 'state', css: ':out-of-range'}, + {name: 'placeholder-shown', type: 'state', css: ':placeholder-shown'}, + {name: 'read-only', type: 'state', css: ':read-only'}, + {name: 'read-write', type: 'state', css: ':read-write'}, + + // State modifiers - structural + {name: 'first', type: 'state', css: ':first-child'}, + {name: 'last', type: 'state', css: ':last-child'}, + {name: 'only', type: 'state', css: ':only-child'}, + {name: 'odd', type: 'state', css: ':nth-child(odd)'}, + {name: 'even', type: 'state', css: ':nth-child(even)'}, + {name: 'empty', type: 'state', css: ':empty'}, + // Note: nth-child(N) and nth-of-type(N) are handled dynamically via parse_parameterized_state + + // State modifiers - UI states + {name: 'fullscreen', type: 'state', css: ':fullscreen'}, + {name: 'modal', type: 'state', css: ':modal'}, + {name: 'popover-open', type: 'state', css: ':popover-open'}, + + // Pseudo-element modifiers + {name: 'before', type: 'pseudo-element', css: '::before'}, + {name: 'after', type: 'pseudo-element', css: '::after'}, + {name: 'placeholder', type: 'pseudo-element', css: '::placeholder'}, + {name: 'selection', type: 'pseudo-element', css: '::selection'}, + {name: 'marker', type: 'pseudo-element', css: '::marker'}, + {name: 'file', type: 'pseudo-element', css: '::file-selector-button'}, + {name: 'backdrop', type: 'pseudo-element', css: '::backdrop'}, +]; + +// Generated lookup maps for efficient access + +/** Map of media modifier names to their CSS output */ +export const MEDIA_MODIFIERS: Map = new Map( + MODIFIERS.filter((m) => m.type === 'media').map((m) => [m.name, m]), +); + +/** Map of ancestor modifier names to their CSS output */ +export const ANCESTOR_MODIFIERS: Map = new Map( + MODIFIERS.filter((m) => m.type === 'ancestor').map((m) => [m.name, m]), +); + +/** Map of state modifier names to their CSS output */ +export const STATE_MODIFIERS: Map = new Map( + MODIFIERS.filter((m) => m.type === 'state').map((m) => [m.name, m]), +); + +/** Map of pseudo-element modifier names to their CSS output */ +export const PSEUDO_ELEMENT_MODIFIERS: Map = new Map( + MODIFIERS.filter((m) => m.type === 'pseudo-element').map((m) => [m.name, m]), +); + +/** All modifier names for quick lookup */ +export const ALL_MODIFIER_NAMES: Set = new Set(MODIFIERS.map((m) => m.name)); + +/** + * Pattern for arbitrary min-width breakpoints: `min-width(800px):` + */ +export const ARBITRARY_MIN_WIDTH_PATTERN = /^min-width\(([^)]+)\)$/; + +/** + * Pattern for arbitrary max-width breakpoints: `max-width(600px):` + */ +export const ARBITRARY_MAX_WIDTH_PATTERN = /^max-width\(([^)]+)\)$/; + +/** + * Pattern for parameterized nth-child: `nth-child(2n+1):` + */ +export const NTH_CHILD_PATTERN = /^nth-child\(([^)]+)\)$/; + +/** + * Pattern for parameterized nth-of-type: `nth-of-type(2n):` + */ +export const NTH_OF_TYPE_PATTERN = /^nth-of-type\(([^)]+)\)$/; + +/** + * Parses an arbitrary breakpoint modifier. + * + * @returns The CSS media query or null if not an arbitrary breakpoint + */ +export const parse_arbitrary_breakpoint = (segment: string): string | null => { + const min_match = ARBITRARY_MIN_WIDTH_PATTERN.exec(segment); + if (min_match) { + return `@media (width >= ${min_match[1]})`; + } + + const max_match = ARBITRARY_MAX_WIDTH_PATTERN.exec(segment); + if (max_match) { + return `@media (width < ${max_match[1]})`; + } + + return null; +}; + +/** + * Parses a parameterized state modifier (nth-child, nth-of-type). + * + * @returns Object with name (including parameter) and CSS, or null if not parameterized + */ +export const parse_parameterized_state = ( + segment: string, +): {name: string; css: string; type: 'state'} | null => { + const nth_child_match = NTH_CHILD_PATTERN.exec(segment); + if (nth_child_match) { + return { + name: segment, + css: `:nth-child(${nth_child_match[1]})`, + type: 'state', + }; + } + + const nth_of_type_match = NTH_OF_TYPE_PATTERN.exec(segment); + if (nth_of_type_match) { + return { + name: segment, + css: `:nth-of-type(${nth_of_type_match[1]})`, + type: 'state', + }; + } + + return null; +}; + +/** + * Gets the modifier definition for a segment. + * Handles both static modifiers and dynamic patterns (arbitrary breakpoints, parameterized states). + * + * @returns The modifier definition or null if not a known modifier + */ +export const get_modifier = ( + segment: string, +): (ModifierDefinition & {is_arbitrary?: boolean}) | null => { + // Check static modifiers first + const media = MEDIA_MODIFIERS.get(segment); + if (media) return media; + + const ancestor = ANCESTOR_MODIFIERS.get(segment); + if (ancestor) return ancestor; + + const state = STATE_MODIFIERS.get(segment); + if (state) return state; + + const pseudo = PSEUDO_ELEMENT_MODIFIERS.get(segment); + if (pseudo) return pseudo; + + // Check arbitrary breakpoints + const arbitrary_css = parse_arbitrary_breakpoint(segment); + if (arbitrary_css) { + return { + name: segment, + type: 'media', + css: arbitrary_css, + is_arbitrary: true, + }; + } + + // Check parameterized state modifiers + const parameterized = parse_parameterized_state(segment); + if (parameterized) { + return { + ...parameterized, + is_arbitrary: true, + }; + } + + return null; +}; + +/** + * Gets all modifier names for error message suggestions. + */ +export const get_all_modifier_names = (): Array => { + return Array.from(ALL_MODIFIER_NAMES).sort(); +}; diff --git a/src/lib/style.css b/src/lib/style.css index 343237556..cfd7f5127 100644 --- a/src/lib/style.css +++ b/src/lib/style.css @@ -136,8 +136,7 @@ Respects `hidden="until-found"` for find-in-page support. :where(:is(h1, h2, h3, h4, h5, h6, .heading):not(.unstyled)) { font-family: var(--font_family_serif); - font-size: var(--font_size, var(--font_size_md)); - font-weight: var(--font_weight); + font-size: var(--font_size, inherit); line-height: var(--line_height_sm); text-wrap: balance; /* @see https://developer.mozilla.org/en-US/docs/Web/CSS/text-wrap#balance */ /* TODO use this pattern elsewhere? provides API to components like `MdnLogo` */ @@ -146,38 +145,38 @@ Respects `hidden="until-found"` for find-in-page support. :where(h1:not(.unstyled)) { --font_size: var(--font_size_xl3); - --font_weight: 300; + font-weight: 300; margin-bottom: var(--space_xl5); /* TODO strange to omit only this one, but seems to be generally my desired behavior */ /* margin-top: var(--space_xl7); */ } :where(h2:not(.unstyled)) { --font_size: var(--font_size_xl2); - --font_weight: 400; + font-weight: 400; margin-bottom: var(--space_xl4); margin-top: var(--space_xl6); } :where(h3:not(.unstyled)) { --font_size: var(--font_size_xl); - --font_weight: 500; + font-weight: 500; margin-bottom: var(--space_xl3); margin-top: var(--space_xl5); } :where(h4:not(.unstyled)) { --font_size: var(--font_size_lg); - --font_weight: 700; + font-weight: 700; margin-bottom: var(--space_xl2); margin-top: var(--space_xl4); } :where(h5:not(.unstyled)) { --font_size: var(--font_size_md); - --font_weight: 900; + font-weight: 900; margin-bottom: var(--space_xl); margin-top: var(--space_xl3); } :where(h6:not(.unstyled)) { --font_size: var(--font_size_sm); - --font_weight: 600; + font-weight: 600; margin-bottom: var(--space_lg); margin-top: var(--space_xl2); text-transform: uppercase; @@ -490,7 +489,6 @@ for disabled colors without needing a wrapper .disabled class */ } :where(label.row:not(.unstyled)) { justify-content: flex-start; - align-items: center; } :where(label.row:not(.unstyled) :is(input[type='checkbox'], input[type='radio']):not(.unstyled)) { margin-right: var(--space_md); diff --git a/src/lib/variable_data.ts b/src/lib/variable_data.ts index bf3886445..0fe59142b 100644 --- a/src/lib/variable_data.ts +++ b/src/lib/variable_data.ts @@ -108,111 +108,6 @@ export const border_width_variants = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const; export type OutlineWidthVariant = ArrayElement; export const outline_width_variants = ['focus', 'active'] as const; -export type AlignmentValue = ArrayElement; -export const alignment_values = ['center', 'start', 'end', 'baseline', 'stretch'] as const; - -export type JustifyValue = ArrayElement; -export const justify_values = [ - 'center', - 'start', - 'end', - 'left', - 'right', - 'space-between', - 'space-around', - 'space-evenly', - 'stretch', -] as const; - -export type OverflowValue = ArrayElement; -export const overflow_values = ['auto', 'hidden', 'scroll', 'clip', 'visible'] as const; - -export type BorderStyleValue = ArrayElement; -export const border_style_values = [ - 'none', - 'hidden', - 'dotted', - 'dashed', - 'solid', - 'double', - 'groove', - 'ridge', - 'inset', - 'outset', -] as const; - -export type DisplayValue = ArrayElement; -export const display_values = [ - 'none', - 'contents', - 'block', - 'flow-root', - 'inline', - 'inline-block', - 'run-in', - 'list-item', - 'inline list-item', - 'flex', - 'inline-flex', - 'grid', - 'inline-grid', - 'ruby', - 'block ruby', - 'table', - 'inline-table', -] as const; - -export type TextAlignValue = ArrayElement; -export const text_align_values = [ - 'start', - 'end', - 'left', - 'right', - 'center', - 'justify', - 'justify-all', - 'match-parent', -] as const; - -export type VerticalAlignValue = ArrayElement; -export const vertical_align_values = [ - 'baseline', - 'sub', - 'super', - 'text-top', - 'text-bottom', - 'middle', - 'top', - 'bottom', -] as const; - -export type WordBreakValue = ArrayElement; -export const word_break_values = ['normal', 'break-all', 'keep-all'] as const; - -export type PositionValue = ArrayElement; -export const position_values = ['static', 'relative', 'absolute', 'fixed', 'sticky'] as const; - -export type VisibilityValue = ArrayElement; -export const visibility_values = ['visible', 'hidden', 'collapse'] as const; - -export type FloatValue = ArrayElement; -export const float_values = ['none', 'left', 'right', 'inline-start'] as const; - -export type FlexWrapValue = ArrayElement; -export const flex_wrap_values = ['nowrap', 'wrap', 'wrap-reverse'] as const; - -export type FlexDirectionValue = ArrayElement; -export const flex_direction_values = ['row', 'row-reverse', 'column', 'column-reverse'] as const; - -export type OverflowWrapValue = ArrayElement; -export const overflow_wrap_values = ['normal', 'anywhere', 'break-word'] as const; - -export type ScrollbarWidthValue = ArrayElement; -export const scrollbar_width_values = ['auto', 'thin', 'none'] as const; - -export type ScrollbarGutterValue = ArrayElement; -export const scrollbar_gutter_values = ['auto', 'stable', 'stable both-edges'] as const; - /** * Maximum value for CSS z-index property (32-bit signed integer max). */ diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b3d649d45..86e70168b 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -28,7 +28,7 @@ - Fuz CSS - magical organic stylesheets + Fuz CSS - CSS with more utility diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 75e5d7efe..3e2c0cb3e 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -17,7 +17,7 @@ fuz_css - magical organic stylesheets 🌿 + CSS with more utility 🌿 @@ -28,7 +28,7 @@ docs{#snippet icon()}🌿{/snippet} - + Fuz CSS is part of the Fuz stack, see fuz.dev and diff --git a/src/routes/FileLink.svelte b/src/routes/FileLink.svelte index 8449552f3..69355b051 100644 --- a/src/routes/FileLink.svelte +++ b/src/routes/FileLink.svelte @@ -31,7 +31,7 @@ -{#if typeof icon === 'string'}{icon}{:else}{@render icon()}{/if} {#if children}{@render children()}{:else}{final_path}{/if} diff --git a/src/routes/FontSizeControl.svelte b/src/routes/FontSizeControl.svelte index 4cf86f737..a5fbeefc3 100644 --- a/src/routes/FontSizeControl.svelte +++ b/src/routes/FontSizeControl.svelte @@ -23,14 +23,14 @@ + > {#if children} {@render children()} {:else} font-size {/if} = + > {#if children} {@render children()} {:else} font-weight {/if} = - import MdnLink from '@fuzdev/fuz_ui/MdnLink.svelte'; import {resolve} from '$app/paths'; import ModuleLink from '$routes/ModuleLink.svelte'; @@ -7,26 +6,25 @@ - Fuz CSS is a CSS framework and design system built around semantic styles and style variables. - Semantic styles mean HTML elements are styled by default, with .unstyled - to opt out when needed. Style variables are design tokens and CSS custom properties with particular capabilities and conventions that integrate with the semantic styles and other features - like utility classes. + Fuz CSS is a framework and design system built on semantic styles and style variables. It styles + HTML elements by default and integrates custom properties, themes, and utility classes into a + coherent system. It's Svelte-first but works with plain HTML/JS/TS, React, Preact, Solid, and + other JSX frameworks. See the + framework support docs and + Fuz UI for the companion Svelte components. The only required parts are a reset stylesheet with the semantic defaults and a replaceable - theme stylesheet containing the variables used in the reset. - There's also a utility class system for convenience and - composition, and it exports the underlying data, types, - and helpers for more complex usage. It works with any website and JS framework, and - Fuz UI - integrates it with Svelte. + theme stylesheet containing the variables used in the reset + -- these require no additional dependencies. There's also a + utility class system + for composition and convenience, and it exports the underlying + data, types, and helpers for more complex usage. - See the docs - and readme. + More at the docs + and repo. diff --git a/src/routes/ThemeForm.svelte b/src/routes/ThemeForm.svelte index eae9fbb76..50badeee9 100644 --- a/src/routes/ThemeForm.svelte +++ b/src/routes/ThemeForm.svelte @@ -65,7 +65,7 @@ - + {#if editing}edit{:else}create{/if} theme
magical organic stylesheets 🌿
CSS with more utility 🌿
- Fuz CSS is a CSS framework and design system built around semantic styles and style variables. - Semantic styles mean HTML elements are styled by default, with .unstyled - to opt out when needed. Style variables are design tokens and CSS custom properties with particular capabilities and conventions that integrate with the semantic styles and other features - like utility classes. + Fuz CSS is a framework and design system built on semantic styles and style variables. It styles + HTML elements by default and integrates custom properties, themes, and utility classes into a + coherent system. It's Svelte-first but works with plain HTML/JS/TS, React, Preact, Solid, and + other JSX frameworks. See the + framework support docs and + Fuz UI for the companion Svelte components.
.unstyled
The only required parts are a reset stylesheet with the semantic defaults and a replaceable - theme stylesheet containing the variables used in the reset. - There's also a utility class system for convenience and - composition, and it exports the underlying data, types, - and helpers for more complex usage. It works with any website and JS framework, and - Fuz UI - integrates it with Svelte. + theme stylesheet containing the variables used in the reset + -- these require no additional dependencies. There's also a + utility class system + for composition and convenience, and it exports the underlying + data, types, and helpers for more complex usage.
- See the docs - and readme. + More at the docs + and repo.