diff --git a/FIX_SUMMARY.md b/FIX_SUMMARY.md new file mode 100644 index 0000000..9a0a49d --- /dev/null +++ b/FIX_SUMMARY.md @@ -0,0 +1,122 @@ +# b_short Fix: Recursive background-position Expansion + +**Branch:** `develop` +**Commit:** `10000d3` +**Date:** 2025-11-15 + +--- + +## Problem + +b_short was NOT fully expanding multi-layer `background-position` values to longhands (`-x` and `-y`). + +**Example:** + +```css +background: url(a.png) center, url(b.png) left top; +``` + +**Before (BROKEN):** + +```css +background-position: center, left top /* ❌ Still a shorthand! */ +``` + +**After (FIXED):** + +```css +background-position-x: center, left /* ✅ Longhands */ +background-position-y: center, top +``` + +This broke @b/values integration because @b/values ONLY supports longhands. + +--- + +## Root Cause + +1. **expand.ts** had an `isMultiLayer` check that SKIPPED recursive expansion for comma-separated values +2. **background-position/expand.ts** only handled single-layer positions + +--- + +## Solution + +### 1. Remove Multi-Layer Check (`src/core/expand.ts`) + +```diff +- const isMultiLayer = hasTopLevelCommas(val); +- if (nestedParse && !isMultiLayer) { ++ if (nestedParse) { +``` + +### 2. Add Multi-Layer Support (`src/handlers/background-position/expand.ts`) + +```typescript +export function expandBackgroundPosition(value: string): Record { + const layers = splitLayers(value); + + if (layers.length === 1) { + // Single layer + const { x, y } = parsePosition(value); + return { + "background-position-x": x, + "background-position-y": y, + }; + } + + // Multi-layer: split, expand each, rejoin + const xValues: string[] = []; + const yValues: string[] = []; + + for (const layer of layers) { + const { x, y } = parsePosition(layer); + xValues.push(x); + yValues.push(y); + } + + return { + "background-position-x": xValues.join(", "), + "background-position-y": yValues.join(", "), + }; +} +``` + +### 3. Update Tests + +- Fixed `test/recursive-expansion.test.ts` expectations +- Fixed `test/invalid-cases.test.ts` expectations + +--- + +## Tests + +✅ **All 929 tests passing** + +Key tests: + +- ✅ Single-layer backgrounds +- ✅ Multi-layer backgrounds +- ✅ Complex gradients with multiple layers +- ✅ Recursive expansion +- ✅ Invalid cases + +--- + +## Breaking Change + +**BREAKING CHANGE:** `background-position` now ALWAYS expands to `-x` and `-y`, even in multi-layer backgrounds. + +Any code expecting `background-position` as output will need to be updated to expect `-x` and `-y`. + +--- + +## Next Steps + +1. ✅ Commit to `develop` branch +2. TODO: Test with @b/values integration +3. TODO: Release as v3.2.0 (breaking change) + +--- + +**Status:** ✅ **COMPLETE** - Ready for integration testing with @b/values diff --git a/docs.llm/llm_src.txt b/docs.llm/llm_src.txt new file mode 100644 index 0000000..7433653 --- /dev/null +++ b/docs.llm/llm_src.txt @@ -0,0 +1,9608 @@ +Excluding patterns: +Documentation for LLMs +Excluding patterns: +Excluding patterns: -not -path */__pycache__/* -not -path */__tests__/* -not -path */.git/* -not -path */.idea/* -not -path */.next/* -not -path */.venv/* -not -path */.vscode/* -not -path */*.spec.py -not -path */*.spec.ts -not -path */*.spec.tsx -not -path */*.test.py -not -path */*.test.ts -not -path */*.test.tsx -not -path */*test.py -not -path */*test.ts -not -path */*test.tsx -not -path */build/* -not -path */dist/* -not -path */me.ts -not -path */node_modules/* -not -path */test_*.py -not -path */test.ts -not -path */test.ts -not -path */tests/* -not -path */venv/* +=== File: src/core/expand.ts === +// b_path:: src/core/expand.ts + +import { parseCssDeclaration, parseInputString, stripComments } from "../internal/parsers"; +import { kebabToCamelCase, objectToCss, sortProperties } from "../internal/property-sorter"; +import { shorthand } from "../internal/shorthand-registry"; +import type { BStyleWarning, ExpandOptions, ExpandResult } from "./schema"; +import { DEFAULT_EXPAND_OPTIONS, FORMAT_CSS } from "./schema"; +import { validate } from "./validate"; + +/** + * Expands CSS shorthand properties into their longhand equivalents. + * + * @param input - CSS declaration(s) to expand (e.g., "margin: 10px 20px;") + * @param options - Configuration options for expansion behavior + * @returns Object containing expansion result, success status, and any issues + * + * @example + * // Basic expansion + * expand('margin: 10px 20px;') + * // → { ok: true, result: 'margin-top: 10px;\nmargin-right: 20px;...', issues: [] } + * + * @example + * // JavaScript object format + * expand('padding: 1rem;', { format: 'js' }) + * // → { ok: true, result: { paddingTop: '1rem', ... }, issues: [] } + * + * @example + * // Multiple declarations with conflict resolution + * expand('margin: 10px; margin-top: 20px;', { format: 'js' }) + * // → { ok: true, result: { marginTop: '20px', marginRight: '10px', ... }, issues: [] } + */ +export function expand(input: string, options: Partial = {}): ExpandResult { + const cleanedInput = stripComments(input); + + // Apply defaults using DEFAULT_EXPAND_OPTIONS + const { + format = DEFAULT_EXPAND_OPTIONS.format, + indent = DEFAULT_EXPAND_OPTIONS.indent, + separator = DEFAULT_EXPAND_OPTIONS.separator, + propertyGrouping = DEFAULT_EXPAND_OPTIONS.propertyGrouping, + } = options; + + const validation = validate(cleanedInput); + + const inputs = parseInputString(cleanedInput); + const finalProperties: Record = {}; + const issues: BStyleWarning[] = []; + + for (const item of inputs) { + const parsed = parseCssDeclaration(item); + if (!parsed) { + continue; + } + + const { property, value } = parsed; + const normalized = value.trim(); + const important = normalized.endsWith("!important") && /\s/.test(normalized.slice(-11, -10)); + + if (important) { + issues.push({ + property, + name: "important-detected", + formattedWarning: `!important flag detected in '${property}' and has been ignored. Shorthand expansion does not support !important.`, + }); + finalProperties[property] = normalized; + continue; + } + + const parse = shorthand[property]; + const longhand = parse?.(normalized); + + if (longhand) { + // Recursively expand any longhands that are themselves shorthands + // e.g., background → background-position → background-position-x/y + // Note: Skip recursive expansion for multi-layer values (e.g., "0% 0%, 0% 0%") + // as they need layer-aware splitting first + for (const [prop, val] of Object.entries(longhand)) { + const nestedParse = shorthand[prop]; + + if (nestedParse) { + const nestedLonghand = nestedParse(val); + if (nestedLonghand) { + Object.assign(finalProperties, nestedLonghand); + continue; + } + } + finalProperties[prop] = val; + } + } else { + finalProperties[property] = normalized; + + if (property in shorthand) { + issues.push({ + property, + name: "expansion-failed", + formattedWarning: `Could not expand shorthand property '${property}' with value '${normalized}'. Returning original shorthand.`, + }); + } + } + } + + let finalResult: Record | string | undefined; + + if (Object.keys(finalProperties).length === 0) { + finalResult = undefined; + } else if (format === FORMAT_CSS) { + finalResult = objectToCss(finalProperties, indent, separator, propertyGrouping); + } else { + const sorted = sortProperties(finalProperties, propertyGrouping); + const camelCased: Record = {}; + for (const [key, value] of Object.entries(sorted)) { + camelCased[kebabToCamelCase(key)] = value; + } + finalResult = camelCased; + } + + const ok = validation.errors.length === 0; + const allIssues = [...validation.errors, ...validation.warnings, ...issues]; + + return { + ok, + result: finalResult, + issues: allIssues, + }; +} + + +=== File: src/core/schema.ts === +// b_path:: src/core/schema.ts + +/** + * Base CSS value types + */ +export type CssValue = string; +export type CssProperty = string; +export type CssDeclaration = string; + +/** + * Output format enum + * - 'css': Returns kebab-case CSS string (e.g., "margin-top: 10px;") + * - 'js': Returns camelCase JavaScript object (e.g., { marginTop: '10px' }) + */ +export const FORMAT_VALUES = ["css", "js"] as const; +export type Format = (typeof FORMAT_VALUES)[number]; + +/** + * Property grouping strategy enum + * - 'by-property': Groups by property type (e.g., all margins, then all borders) + * - 'by-side': Groups by directional side (e.g., all top properties, then all right properties) + */ +export const PROPERTY_GROUPING_VALUES = ["by-property", "by-side"] as const; +export type PropertyGrouping = (typeof PROPERTY_GROUPING_VALUES)[number]; + +// Named constants for better readability +export const FORMAT_CSS = "css" as const; +export const FORMAT_JS = "js" as const; +export const GROUPING_BY_PROPERTY = "by-property" as const; +export const GROUPING_BY_SIDE = "by-side" as const; + +/** + * Options for CSS shorthand expansion + */ +export interface ExpandOptions { + /** Output format: "css" (kebab-case string) or "js" (camelCase object). Default: "css" */ + format?: Format; + + /** Indentation spaces for CSS output (min: 0). Default: 0 */ + indent?: number; + + /** Separator between CSS declarations. Default: "\n" */ + separator?: string; + + /** + * Property grouping strategy. Default: "by-property" + * - 'by-property': Groups by property type (e.g., all margins, then all borders) + * - 'by-side': Groups by directional side (e.g., all top properties, then all right properties) + */ + propertyGrouping?: PropertyGrouping; +} + +/** + * Enum-style namespace for ExpandOptions values + * Provides better autocomplete and discoverability + * + * @example + * ```typescript + * import * as b from 'b_short'; + * + * b.expand('background: red', { + * format: b.ExpandOptions.Format.CSS, + * propertyGrouping: b.ExpandOptions.PropertyGrouping.BY_PROPERTY, + * separator: b.ExpandOptions.Separator.NEWLINE, + * indent: b.ExpandOptions.Indent.NONE + * }); + * ``` + */ +export namespace ExpandOptions { + /** + * Output format values + */ + export enum Format { + /** CSS format: kebab-case strings like "margin-top: 10px;" */ + CSS = "css", + /** JS format: camelCase objects like { marginTop: '10px' } */ + JS = "js", + } + + /** + * Property grouping strategy values + */ + export enum PropertyGrouping { + /** Group by property type (all margins, then all borders) */ + BY_PROPERTY = "by-property", + /** Group by directional side (all top properties, then all right) */ + BY_SIDE = "by-side", + } + + /** + * Common separator values + */ + export enum Separator { + /** Newline separator (default) */ + NEWLINE = "\n", + /** Space separator */ + SPACE = " ", + /** Semicolon with space */ + SEMICOLON = "; ", + /** Empty string (compact) */ + NONE = "", + } + + /** + * Common indentation values + */ + export enum Indent { + /** No indentation (default) */ + NONE = 0, + /** 2 spaces */ + TWO_SPACES = 2, + /** 4 spaces */ + FOUR_SPACES = 4, + /** Tab character (8 spaces equivalent) */ + TAB = 8, + } +} + +/** + * Default values for ExpandOptions + * Users can spread this and override specific values + * + * @example + * ```typescript + * import { expand, DEFAULT_EXPAND_OPTIONS } from 'b_short'; + * + * const myOptions = { + * ...DEFAULT_EXPAND_OPTIONS, + * indent: 2, + * format: 'js' + * }; + * + * expand('margin: 10px', myOptions); + * ``` + */ +export const DEFAULT_EXPAND_OPTIONS: Required = { + format: "css", + indent: 0, + separator: "\n", + propertyGrouping: "by-property", +}; + +/** + * CSS Tree syntax parsing error + */ +export interface CssTreeSyntaxParseError { + name: string; + message: string; + line?: number; + column?: number; + property?: string; + offset?: number; + length?: number; +} + +/** + * Custom warning for CSS property validation + */ +export interface BStyleWarning { + /** CSS property that has the warning */ + property: string; + /** Warning name/type */ + name: string; + /** CSS syntax that caused the warning */ + syntax?: string; + /** Formatted warning message for display */ + formattedWarning?: string; +} + +/** + * Single layer in a multi-layer background + */ +export interface BackgroundLayer { + /** Background image (url, gradient, or none) */ + image?: string; + /** Background position (e.g., '10px 20px') */ + position?: string; + /** Background size (e.g., 'cover', '100px 200px') */ + size?: string; + /** Background repeat (e.g., 'no-repeat', 'repeat-x') */ + repeat?: string; + /** Background attachment (e.g., 'fixed', 'scroll') */ + attachment?: string; + /** Background origin (e.g., 'padding-box', 'border-box') */ + origin?: string; + /** Background clip (e.g., 'padding-box', 'border-box') */ + clip?: string; +} + +/** + * Result of parsing multi-layer background declaration + */ +export interface BackgroundResult { + /** Array of background layers */ + layers: BackgroundLayer[]; + /** Global background color applied to all layers */ + color?: string; +} + +/** + * Single layer in a multi-layer mask + */ +export interface MaskLayer { + /** Mask image (url, gradient, or none) */ + image?: string; + /** Masking mode (e.g., 'alpha', 'luminance', 'match-source') */ + mode?: string; + /** Mask position (e.g., '10px 20px', 'center') */ + position?: string; + /** Mask size (e.g., 'cover', '100px 200px', 'auto') */ + size?: string; + /** Mask repeat (e.g., 'no-repeat', 'repeat-x') */ + repeat?: string; + /** Mask origin (e.g., 'padding-box', 'border-box', 'content-box') */ + origin?: string; + /** Mask clip (e.g., 'padding-box', 'border-box', 'content-box', 'no-clip') */ + clip?: string; + /** Mask composite (e.g., 'add', 'subtract', 'intersect', 'exclude') */ + composite?: string; +} + +/** + * Result of parsing multi-layer mask declaration + */ +export interface MaskResult { + /** Array of mask layers */ + layers: MaskLayer[]; +} + +/** + * Single layer in a multi-layer transition + */ +export interface TransitionLayer { + /** Transition property (e.g., 'opacity', 'all', 'transform') */ + property?: string; + /** Transition duration (e.g., '300ms', '0.5s') */ + duration?: string; + /** Transition timing function (e.g., 'ease', 'cubic-bezier(0.4, 0, 0.2, 1)') */ + timingFunction?: string; + /** Transition delay (e.g., '100ms', '0s') */ + delay?: string; +} + +/** + * Result of parsing multi-layer transition declaration + */ +export interface TransitionResult { + /** Array of transition layers */ + layers: TransitionLayer[]; +} + +/** + * Single layer in a multi-layer animation + */ +export interface AnimationLayer { + /** Animation name (e.g., 'spin', 'none') */ + name?: string; + /** Animation duration (e.g., '1s', '300ms') */ + duration?: string; + /** Animation timing function (e.g., 'ease', 'cubic-bezier(0.4, 0, 0.2, 1)') */ + timingFunction?: string; + /** Animation delay (e.g., '100ms', '0s') */ + delay?: string; + /** Animation iteration count (e.g., '3', 'infinite') */ + iterationCount?: string; + /** Animation direction (e.g., 'normal', 'reverse', 'alternate', 'alternate-reverse') */ + direction?: string; + /** Animation fill mode (e.g., 'none', 'forwards', 'backwards', 'both') */ + fillMode?: string; + /** Animation play state (e.g., 'running', 'paused') */ + playState?: string; +} + +/** + * Result of parsing multi-layer animation declaration + */ +export interface AnimationResult { + /** Array of animation layers */ + layers: AnimationLayer[]; +} + +/** + * Result of CSS shorthand expansion + */ +export interface ExpandResult { + /** Whether expansion was successful (no syntax errors) */ + ok: boolean; + /** + * The expanded CSS result + * - JavaScript object format (multiple declarations are merged, with later properties overriding earlier ones) + * - CSS string format (multiple declarations are joined) + * - undefined when input is empty or invalid + */ + result?: Record | string; + /** Array of syntax errors and validation warnings */ + issues: Array; +} + +/** + * Result of CSS stylesheet validation + */ +export interface StylesheetValidation { + /** Whether validation passed (no errors) */ + ok: boolean; + /** Array of syntax parsing errors */ + errors: CssTreeSyntaxParseError[]; + /** Array of property validation warnings */ + warnings: BStyleWarning[]; +} + +// ============================================================================ +// v2.0.0: Collapse types removed - Expansion only +// ============================================================================ + + +=== File: src/core/validate.ts === +// b_path:: src/core/validate.ts +/** + * Validates CSS stylesheet syntax and property values, providing detailed error formatting. + * + * This function parses CSS using the css-tree library and validates each CSS property + * against the CSS specification. When validation errors are found, it generates + * formatted error messages with visual context including line numbers, code snippets, + * and precise error location indicators. + * + * @param css - The CSS string to validate + * + * @returns StylesheetValidation object containing: + * - ok: boolean indicating if validation passed (no errors) + * - errors: Array of syntax parsing errors (malformed CSS) + * - warnings: Array of property validation errors (invalid property values) + * + * @remarks + * - Context window shows ±2 lines around each error for better debugging + * - Long lines are intelligently truncated with ellipses (…) for readability + * - Pointer indicators (^^^) precisely mark the error location and length + * - Duplicate declarations are automatically deduplicated to avoid redundant warnings + * + * @throws Does not throw - parsing errors are captured in the returned errors array + * + * @since 1.0.0 + */ + +import * as csstree from "@eslint/css-tree"; +import type { BStyleWarning, StylesheetValidation } from "./schema"; + +// Constants +const DEFAULT_MAX_LINE_WIDTH = 80; +const LINE_NUMBER_PADDING = 4; +const DEFAULT_CONTEXT_WINDOW_SIZE = 2; // Lines before and after error + +export interface BStyleMatchError extends csstree.SyntaxMatchError { + property: string; + formattedError?: string; +} + +export type { BStyleWarning, StylesheetValidation }; + +export interface Declaration { + property: string; + value: csstree.Value | csstree.Raw; + node: csstree.CssNode; +} + +export interface ErrorFormatOptions { + maxLineWidth: number; + contextWindowSize?: number; +} + +interface TruncationBounds { + startPos: number; + endPos: number; + needsStartEllipsis: boolean; + needsEndEllipsis: boolean; + availableWidth: number; +} + +interface FormattedLine { + content: string; + adjustedColumn: number; +} + +/** + * Checks if a CSS value node contains var() function. + * CSS variables cannot be validated by @eslint/css-tree as they are runtime values. + */ +function containsVar(node: csstree.CssNode): boolean { + if (!node) return false; + if (node.type === "Function" && node.name === "var") return true; + if ("children" in node && node.children) { + for (const child of node.children) { + if (containsVar(child)) return true; + } + } + return false; +} + +/** + * Validates a CSS stylesheet for syntax and property value errors. + * + * @param css - The CSS string to validate + * @returns StylesheetValidation object containing validation results + */ +export function validate(css: string): StylesheetValidation { + const errors: csstree.SyntaxParseError[] = []; + const warnings: BStyleMatchError[] = []; + const declarations: Declaration[] = []; + const syntax = csstree.lexer; + const uniqueDecls = new Map(); + + // Parse CSS + const ast = csstree.parse(css, { + context: "declarationList", + positions: true, + parseAtrulePrelude: true, + parseRulePrelude: true, + parseCustomProperty: true, + onParseError(err: csstree.SyntaxParseError) { + // biome-ignore lint/correctness/noUnusedVariables: remove stack from err + const { stack, ...rest } = err; + errors.push(rest); + }, + }); + + // Extract declarations + csstree.walk(ast, (node) => { + if (node.type !== "Declaration") { + return; + } + const id = csstree.generate(node); + if (uniqueDecls.has(id)) { + uniqueDecls.set(id, uniqueDecls.get(id)! + 1); + return; + } + uniqueDecls.set(id, 1); + declarations.push({ property: node.property, value: node.value, node }); + }); + + // Validate declarations + // Suppress noisy csstree-match iteration warnings during matching. + // Control via env var: BStyle_CSSTREE_LOG_LEVEL=ERROR (default) suppresses these messages. + const suppressNoise = (msg: unknown): boolean => { + try { + const s = String(msg); + return /\[csstree-match\]\s*BREAK after/i.test(s); + } catch { + return false; + } + }; + const LOG_LEVEL = (process.env.BStyle_CSSTREE_LOG_LEVEL || "ERROR").toUpperCase(); + const QUIET = LOG_LEVEL === "ERROR" || LOG_LEVEL === "SILENT"; + const origWarn = console.warn; + const origError = console.error; + try { + if (QUIET) { + console.warn = (...args: Parameters) => { + if (args.length && suppressNoise(args[0])) return; + return origWarn(...args); + }; + console.error = (...args: Parameters) => { + if (args.length && suppressNoise(args[0])) return; + return origError(...args); + }; + } + + for (const decl of declarations) { + // Skip validation for declarations containing CSS variables (var()) + // as they cannot be validated at parse time + if (containsVar(decl.value)) { + continue; + } + + const match = syntax.matchProperty(decl.property, decl.value); + const error = match.error as csstree.SyntaxMatchError; + + if (!error) continue; + + // biome-ignore lint/correctness/noUnusedVariables: remove stack from error + const { stack, name, ...rest } = error; + warnings.push({ + property: decl.property, + name, + ...rest, + }); + } + } finally { + // Always restore console methods + console.warn = origWarn; + console.error = origError; + } + + // Format and display warnings + const formattedWarnings: BStyleWarning[] = []; + + if (warnings.length > 0) { + const cssLines = css.split("\n"); + for (const warning of warnings) { + const formattedError = formatErrorDisplay(cssLines, warning); + formattedWarnings.push({ + property: warning.property, + name: warning.name, + syntax: warning.syntax, + formattedWarning: `Errors found in: ${warning.property}\n${formattedError.join("\n")}`, + }); + } + } + + // Return result with runtime type safety + const result: StylesheetValidation = { + ok: errors.length === 0, + errors, + warnings: formattedWarnings, + }; + + return result; +} + +export function validateDeclaration(value: string, prop: string): StylesheetValidation { + const css = `.class {${prop}: ${value};}`; + const result = validate(css); + return result; +} + +// Helper functions +/** + * Calculates the line window to display around an error line. + * Shows contextWindowSize lines before and after the error for better context. + * + * @param errorLine - The line number where the error occurred + * @param totalLines - Total number of lines in the CSS + * @param contextWindowSize - Number of lines to show before and after error (default: 2) + * @returns Object with start and end line numbers for the context window + */ +function calculateLineWindow( + errorLine: number, + totalLines: number, + contextWindowSize: number = DEFAULT_CONTEXT_WINDOW_SIZE +): { start: number; end: number } { + const start = Math.max(1, errorLine - contextWindowSize); + const end = Math.min(totalLines, errorLine + contextWindowSize); + return { start, end }; +} + +function formatLineNumber(lineNum: number, maxLineNum: number): string { + const maxDigits = Math.max(maxLineNum.toString().length, 1); + const paddedNum = lineNum.toString().padStart(maxDigits, " "); + const prefix = " ".repeat(LINE_NUMBER_PADDING - maxDigits); + return `${prefix}${paddedNum} |`; +} + +function trimLine(line: string): { trimmed: string; spacesRemoved: number } { + const trimmed = line.trimStart(); + const spacesRemoved = line.length - trimmed.length; + return { trimmed, spacesRemoved }; +} + +function calculateTruncationBounds( + lineLength: number, + errorColumn: number, + maxWidth: number +): TruncationBounds { + if (lineLength <= maxWidth) { + return { + startPos: 0, + endPos: lineLength, + needsStartEllipsis: false, + needsEndEllipsis: false, + availableWidth: maxWidth, + }; + } + + // Reserve space for potential ellipses + let availableWidth = maxWidth - 2; + const halfWidth = Math.floor(availableWidth / 2); + let startPos = Math.max(0, errorColumn - halfWidth - 1); + + // Determine if we need start ellipsis + const needsStartEllipsis = startPos > 0; + + if (!needsStartEllipsis) { + // No start truncation - reclaim space for end-only ellipsis + availableWidth = maxWidth - 1; + } else { + // Skip one additional character for better spacing after ellipsis + startPos = startPos + 1; + } + + let endPos = startPos + availableWidth; + let needsEndEllipsis = endPos < lineLength; + + // Adjust if we hit the end of the line + if (endPos >= lineLength) { + endPos = lineLength; + needsEndEllipsis = false; + + if (needsStartEllipsis) { + startPos = Math.max(0, endPos - availableWidth); + } + } + + return { + startPos, + endPos, + needsStartEllipsis, + needsEndEllipsis, + availableWidth, + }; +} + +function applyTruncation( + line: string, + bounds: TruncationBounds, + originalErrorColumn: number +): FormattedLine { + let content = line.slice(bounds.startPos, bounds.endPos); + let adjustedColumn = originalErrorColumn - bounds.startPos; + + if (bounds.needsStartEllipsis) { + content = `…${content}`; + adjustedColumn = adjustedColumn + 1; + } + + if (bounds.needsEndEllipsis) { + content = `${content}…`; + } + + return { content, adjustedColumn }; +} + +function formatContextLine(line: string, maxWidth: number): string { + const { trimmed } = trimLine(line); + + if (trimmed.length <= maxWidth) { + return trimmed; + } + + return `${trimmed.slice(0, maxWidth - 1)}…`; +} + +function formatErrorLine(line: string, errorColumn: number, maxWidth: number): FormattedLine { + // Input validation + if (errorColumn < 1) { + throw new Error("Error column must be >= 1"); + } + + const { trimmed, spacesRemoved } = trimLine(line); + const adjustedErrorColumn = Math.max(1, errorColumn - spacesRemoved); + + // Handle case where line fits without truncation after trimming + if (trimmed.length <= maxWidth) { + return { + content: trimmed, + adjustedColumn: adjustedErrorColumn, + }; + } + + // Calculate truncation bounds using the original error column position + const bounds = calculateTruncationBounds(line.length, errorColumn, maxWidth); + + // Special handling for start-of-line case (after trimming consideration) + if (bounds.startPos === 0 || bounds.startPos <= spacesRemoved) { + // Use trimmed line for start-of-line truncation + const trimmedBounds = calculateTruncationBounds(trimmed.length, adjustedErrorColumn, maxWidth); + return applyTruncation(trimmed, trimmedBounds, adjustedErrorColumn); + } + + // Standard middle-of-line truncation + return applyTruncation(line, bounds, errorColumn); +} + +function createPointerLine(prefixLength: number, column: number, length: number): string { + const safeLength = Math.max(1, length ?? 1); + const safeColumn = Math.max(1, column); + + const pointerPrefix = " ".repeat(prefixLength); + const dashes = "-".repeat(safeColumn - 1); + const carets = "^".repeat(safeLength); + + return pointerPrefix + dashes + carets; +} + +/** + * Formats and displays CSS validation errors with visual context. + * Shows the error line with surrounding context, line numbers, and pointer indicators. + * + * @param cssLines - Array of CSS source lines + * @param warning - The validation error/warning to format + * @param options - Formatting options (maxLineWidth, contextWindowSize) + * @returns Array of formatted strings representing the error display + * + * @example + * // Error at line 5, column 10: + * // 3 | .class { + * // 4 | margin: 10px; + * // 5 | color: notacolor; + * // ---------^^^^^^^^^^^ + * // 6 | } + */ +function formatErrorDisplay( + cssLines: string[], + warning: BStyleMatchError, + options: ErrorFormatOptions = { + maxLineWidth: DEFAULT_MAX_LINE_WIDTH, + contextWindowSize: DEFAULT_CONTEXT_WINDOW_SIZE, + } +): string[] { + // Input validation + if (!cssLines.length || warning.line < 1 || warning.line > cssLines.length) { + return [`Invalid error location: line ${warning.line}`]; + } + const errorLine = warning.line; + const errorColumn = warning.column; + const mismatchLength = warning.mismatchLength ?? 1; + const contextWindowSize = options.contextWindowSize ?? DEFAULT_CONTEXT_WINDOW_SIZE; + + const { start, end } = calculateLineWindow(errorLine, cssLines.length, contextWindowSize); + const maxLineNum = end; + const linePrefix = formatLineNumber(1, maxLineNum); + const prefixLength = linePrefix.length; + const availableWidth = options.maxLineWidth - prefixLength; + + const result: string[] = []; + + for (let lineNum = start; lineNum <= end; lineNum++) { + const lineIndex = lineNum - 1; + const currentLine = cssLines[lineIndex] ?? ""; + const currentPrefix = formatLineNumber(lineNum, maxLineNum); + + if (lineNum === errorLine) { + // Format error line + const { content, adjustedColumn } = formatErrorLine(currentLine, errorColumn, availableWidth); + + result.push(currentPrefix + content); + + // Add pointer line + const pointerLine = createPointerLine(prefixLength, adjustedColumn, mismatchLength); + result.push(pointerLine); + } else { + // Format context line + const formattedLine = formatContextLine(currentLine, availableWidth); + result.push(currentPrefix + formattedLine); + } + } + + return result; +} + + +=== File: src/handlers/animation/animation-layers.ts === +// b_path:: src/handlers/animation/animation-layers.ts + +import * as csstree from "@eslint/css-tree"; +import type { AnimationLayer, AnimationResult } from "@/core/schema"; +import isTime from "@/internal/is-time"; +import isTimingFunction from "@/internal/is-timing-function"; +import { matchesType } from "@/internal/is-value-node"; +import { hasTopLevelCommas, parseLayersGeneric } from "@/internal/layer-parser-utils"; + +// CSS default values for animation properties +export const ANIMATION_DEFAULTS = { + name: "none", + duration: "0s", + timingFunction: "ease", + delay: "0s", + iterationCount: "1", + direction: "normal", + fillMode: "none", + playState: "running", +} as const; + +/** + * Detects if an animation value needs advanced parsing (multi-layer animations or complex functions) + */ +export function needsAdvancedParser(value: string): boolean { + return hasTopLevelCommas(value, true); +} + +/** + * Parses a complex animation value using css-tree AST parsing + */ +export function parseAnimationLayers(value: string): AnimationResult | undefined { + const layers = parseLayersGeneric(value, parseSingleLayer); + return layers ? { layers } : undefined; +} + +/** + * Parses a single animation layer using css-tree AST parsing + */ +function parseSingleLayer(layerValue: string): AnimationLayer | undefined { + const result: AnimationLayer = {}; + + const ast = csstree.parse(layerValue.trim(), { context: "value" }); + + // Collect all child nodes from the Value node + const children: csstree.CssNode[] = []; + csstree.walk(ast, { + visit: "Value", + enter: (node: csstree.CssNode) => { + if (node.type === "Value" && node.children) { + node.children.forEach((child) => { + children.push(child); + }); + } + }, + }); + + // Process children in order, handling animation property parsing + if (!processCssChildren(children, result)) { + return undefined; // Parsing failed due to invalid syntax + } + + return result; +} + +/** + * Processes CSS AST children sequentially to extract animation properties + * + * This function handles the parsing of CSS animation layer syntax, + * including animation names, time values, timing functions, iteration counts, + * direction, fill mode, and play state. + * CSS ordering rules: first time = duration, second time = delay + * + * Returns false if parsing should fail (e.g., too many time values, unrecognized tokens, duplicates) + */ +function processCssChildren(children: csstree.CssNode[], result: AnimationLayer): boolean { + let timeCount = 0; // Track first vs second time value + + for (const child of children) { + let matched = false; // Track if this token was recognized + + // Skip whitespace and operators + if (child.type === "WhiteSpace" || child.type === "Operator") { + continue; + } + + // Handle animation name first (can be any identifier including var() or quoted strings) + if (!result.name) { + if (child.type === "Identifier") { + const name = (child as csstree.Identifier).name; + if (name === "none") { + result.name = "none"; + matched = true; + } + // Check if it looks like a valid animation name identifier + // Allow custom identifiers matching pattern, but exclude timing functions and other keywords + else if ( + /^-?[a-zA-Z][a-zA-Z0-9-]*$/.test(name) && + !isTimingFunction(name) && + !/^(normal|reverse|alternate|alternate-reverse|none|forwards|backwards|both|running|paused|infinite)$/i.test( + name + ) + ) { + result.name = name; + matched = true; + } + } else if (child.type === "Function") { + const funcValue = csstree.generate(child); + if (funcValue.startsWith("var(")) { + result.name = funcValue; + matched = true; + } + } else if (child.type === "String") { + result.name = csstree.generate(child); + matched = true; + } + + if (matched) continue; + } + + // Handle timing functions FIRST (keywords and functions) + // Must check before time values to avoid cubic-bezier() being treated as time + if (!result.timingFunction) { + if (child.type === "Identifier") { + const timingValue = csstree.generate(child); + if (isTimingFunction(timingValue)) { + result.timingFunction = timingValue; + matched = true; + } + } + + if (child.type === "Function") { + const funcValue = csstree.generate(child); + if (isTimingFunction(funcValue)) { + // Fix spacing in function calls (css-tree removes spaces after commas) + result.timingFunction = funcValue.replace(/,([^\s])/g, ", $1"); + matched = true; + } + } + } else { + // Check for duplicates + if (child.type === "Identifier") { + const timingValue = csstree.generate(child); + if (isTimingFunction(timingValue)) { + return false; // Duplicate timing function + } + } + if (child.type === "Function") { + const funcValue = csstree.generate(child); + if (isTimingFunction(funcValue)) { + return false; // Duplicate timing function + } + } + } + + // Handle time values (duration and delay) + // Accept: Dimensions with time units (1s, 500ms), or any Function (calc, var, etc.) + if (!matched && matchesType(child, ["Dimension", "Function"])) { + const timeValue = csstree.generate(child); + + // For Dimensions, validate they have time units + // For Functions (var, calc), accept unconditionally (carry over as-is) + if (child.type === "Function" || isTime(timeValue)) { + if (timeCount >= 2) { + // More than 2 time values is invalid + return false; + } + if (timeCount === 0) { + result.duration = timeValue; + } else { + result.delay = timeValue; + } + timeCount++; + matched = true; + } + } + + // Handle iteration count + // Accept: Number, "infinite" keyword, or any Function (calc, var, etc.) + if (!matched && !result.iterationCount) { + if (matchesType(child, ["Number", "Function"])) { + const numValue = csstree.generate(child); + result.iterationCount = numValue; + matched = true; + } + if (child.type === "Identifier") { + const identValue = csstree.generate(child); + if (identValue === "infinite") { + result.iterationCount = "infinite"; + matched = true; + } + } + } else if (!matched) { + // Check for duplicates + if (matchesType(child, ["Number", "Function"])) { + return false; // Duplicate iteration count + } + if (child.type === "Identifier") { + const identValue = csstree.generate(child); + if (identValue === "infinite") { + return false; // Duplicate iteration count + } + } + } + + // Handle direction keywords + if (!result.direction) { + if (child.type === "Identifier") { + const identValue = csstree.generate(child); + if (/^(normal|reverse|alternate|alternate-reverse)$/i.test(identValue)) { + result.direction = identValue; + matched = true; + } + } + } else { + // Check for duplicates + if (child.type === "Identifier") { + const identValue = csstree.generate(child); + if (/^(normal|reverse|alternate|alternate-reverse)$/i.test(identValue)) { + return false; // Duplicate direction + } + } + } + + // Handle fill mode keywords + if (!result.fillMode) { + if (child.type === "Identifier") { + const identValue = csstree.generate(child); + if (/^(none|forwards|backwards|both)$/i.test(identValue)) { + result.fillMode = identValue; + matched = true; + } + } + } else { + // Check for duplicates + if (child.type === "Identifier") { + const identValue = csstree.generate(child); + if (/^(none|forwards|backwards|both)$/i.test(identValue)) { + return false; // Duplicate fill mode + } + } + } + + // Handle play state keywords + if (!result.playState) { + if (child.type === "Identifier") { + const identValue = csstree.generate(child); + if (/^(running|paused)$/i.test(identValue)) { + result.playState = identValue; + matched = true; + } + } + } else { + // Check for duplicates + if (child.type === "Identifier") { + const identValue = csstree.generate(child); + if (/^(running|paused)$/i.test(identValue)) { + return false; // Duplicate play state + } + } + } + + // If token was not matched by any category, it's unrecognized + if (!matched) { + return false; + } + } + + return true; +} + +/** + * Reconstructs final CSS properties from layer objects + */ +export function reconstructLayers(layers: AnimationLayer[]): Record { + const result: Record = {}; + + // Collect all layer values for each property + const properties = { + "animation-name": layers.map((l) => l.name || ANIMATION_DEFAULTS.name), + "animation-duration": layers.map((l) => l.duration || ANIMATION_DEFAULTS.duration), + "animation-timing-function": layers.map( + (l) => l.timingFunction || ANIMATION_DEFAULTS.timingFunction + ), + "animation-delay": layers.map((l) => l.delay || ANIMATION_DEFAULTS.delay), + "animation-iteration-count": layers.map( + (l) => l.iterationCount || ANIMATION_DEFAULTS.iterationCount + ), + "animation-direction": layers.map((l) => l.direction || ANIMATION_DEFAULTS.direction), + "animation-fill-mode": layers.map((l) => l.fillMode || ANIMATION_DEFAULTS.fillMode), + "animation-play-state": layers.map((l) => l.playState || ANIMATION_DEFAULTS.playState), + }; + + // Join layer values with commas + Object.entries(properties).forEach(([property, values]) => { + result[property] = values.join(", "); + }); + + return result; +} + + +=== File: src/handlers/animation/expand.ts === +// b_path:: src/handlers/animation/expand.ts + +// NOTE: This handler contains complex multi-layer parsing logic that is a candidate +// for future refactoring. The current implementation works correctly but could be +// simplified with better abstractions for timing/iteration/direction parsing. + +import isTime from "@/internal/is-time"; +import isTimingFunction from "@/internal/is-timing-function"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; +import { + ANIMATION_DEFAULTS, + needsAdvancedParser, + parseAnimationLayers, + reconstructLayers, +} from "./animation-layers"; + +const KEYWORD = /^(inherit|initial|unset|revert)$/i; +const ITERATION_COUNT = /^(infinite|[0-9]+(\.[0-9]+)?)$/; +const DIRECTION = /^(normal|reverse|alternate|alternate-reverse)$/i; +const FILL_MODE = /^(none|forwards|backwards|both)$/i; +const PLAY_STATE = /^(running|paused)$/i; + +function parseAnimationValue(value: string): Record | undefined { + // Handle global keywords first + if (KEYWORD.test(value.trim())) { + return { + "animation-name": value.trim(), + "animation-duration": value.trim(), + "animation-timing-function": value.trim(), + "animation-delay": value.trim(), + "animation-iteration-count": value.trim(), + "animation-direction": value.trim(), + "animation-fill-mode": value.trim(), + "animation-play-state": value.trim(), + }; + } + + // Check for multi-layer syntax + if (needsAdvancedParser(value)) { + const layeredResult = parseAnimationLayers(value); + if (layeredResult) { + return reconstructLayers(layeredResult.layers); + } + return undefined; // Advanced parsing failed + } + + // Simple single-layer fallback parser + const result: Record = {}; + const tokens = value.trim().split(/\s+/); + let timeCount = 0; // Track first vs second time value + + for (const token of tokens) { + // Handle animation name first (can be any identifier including var() or quoted strings) + if (!result["animation-name"]) { + if (token === "none") { + result["animation-name"] = "none"; + continue; + } + // Check if it looks like a valid animation name identifier + // Allow custom identifiers matching pattern, var() functions, quoted strings, but exclude timing functions and other keywords + if ( + (/^-?[a-zA-Z][a-zA-Z0-9-]*$/.test(token) || + token.startsWith("var(") || + (token.startsWith('"') && token.endsWith('"')) || + (token.startsWith("'") && token.endsWith("'"))) && + !isTimingFunction(token) && + !DIRECTION.test(token) && + !FILL_MODE.test(token) && + !PLAY_STATE.test(token) && + !ITERATION_COUNT.test(token) + ) { + result["animation-name"] = token; + continue; + } + } + + // Handle time values (duration and delay) - CSS allows flexible ordering + if (isTime(token)) { + if (timeCount === 0) { + result["animation-duration"] = token; + } else if (timeCount === 1) { + result["animation-delay"] = token; + } else { + // More than 2 time values is invalid + return undefined; + } + timeCount++; + continue; + } + + // Handle timing functions + if (!result["animation-timing-function"] && isTimingFunction(token)) { + result["animation-timing-function"] = token; + continue; + } + + // Handle iteration count + if (!result["animation-iteration-count"] && ITERATION_COUNT.test(token)) { + result["animation-iteration-count"] = token; + continue; + } + + // Handle direction keywords + if (!result["animation-direction"] && DIRECTION.test(token)) { + result["animation-direction"] = token; + continue; + } + + // Handle fill mode keywords + if (!result["animation-fill-mode"] && FILL_MODE.test(token)) { + result["animation-fill-mode"] = token; + continue; + } + + // Handle play state keywords + if (!result["animation-play-state"] && PLAY_STATE.test(token)) { + result["animation-play-state"] = token; + continue; + } + + // If token doesn't match any category, it's invalid + return undefined; + } + + // Accept single-token property values - they will expand to defaults + + // Build final result with defaults + return { + "animation-name": result["animation-name"] || ANIMATION_DEFAULTS.name, + "animation-duration": result["animation-duration"] || ANIMATION_DEFAULTS.duration, + "animation-timing-function": + result["animation-timing-function"] || ANIMATION_DEFAULTS.timingFunction, + "animation-delay": result["animation-delay"] || ANIMATION_DEFAULTS.delay, + "animation-iteration-count": + result["animation-iteration-count"] || ANIMATION_DEFAULTS.iterationCount, + "animation-direction": result["animation-direction"] || ANIMATION_DEFAULTS.direction, + "animation-fill-mode": result["animation-fill-mode"] || ANIMATION_DEFAULTS.fillMode, + "animation-play-state": result["animation-play-state"] || ANIMATION_DEFAULTS.playState, + }; +} + +/** + * Property handler for the 'animation' CSS shorthand property + * + * Expands animation into animation-name, animation-duration, animation-timing-function, + * animation-delay, animation-iteration-count, animation-direction, animation-fill-mode, + * and animation-play-state. + * + * @example + * ```typescript + * animationHandler.expand('slide 300ms ease-in'); + * animationHandler.expand('bounce 1s infinite'); + * ``` + */ +export const animationHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "animation", + longhands: [ + "animation-name", + "animation-duration", + "animation-timing-function", + "animation-delay", + "animation-iteration-count", + "animation-direction", + "animation-fill-mode", + "animation-play-state", + ], + defaults: { + "animation-name": ANIMATION_DEFAULTS.name, + "animation-duration": ANIMATION_DEFAULTS.duration, + "animation-timing-function": ANIMATION_DEFAULTS.timingFunction, + "animation-delay": ANIMATION_DEFAULTS.delay, + "animation-iteration-count": ANIMATION_DEFAULTS.iterationCount, + "animation-direction": ANIMATION_DEFAULTS.direction, + "animation-fill-mode": ANIMATION_DEFAULTS.fillMode, + "animation-play-state": ANIMATION_DEFAULTS.playState, + }, + category: "animation", + }, + + expand: (value: string): Record | undefined => { + return parseAnimationValue(value); + }, + + validate: (value: string): boolean => { + return animationHandler.expand(value) !== undefined; + }, +}); + +export default function animation(value: string): Record | undefined { + return animationHandler.expand(value); +} + + +=== File: src/handlers/animation/index.ts === +// b_path:: src/handlers/animation/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/background-position/expand.ts === +// b_path:: src/handlers/background-position/expand.ts + +import { splitLayers } from "../../internal/layer-parser-utils"; +import { parsePosition } from "../../internal/position-parser"; + +/** + * Expand background-position shorthand to longhand properties + * + * background-position → background-position-x, background-position-y + * + * Handles both single and multi-layer values: + * - "center" → { x: "center", y: "center" } + * - "center, left top" → { x: "center, left", y: "center, top" } + */ +export function expandBackgroundPosition(value: string): Record { + // Check if we have multiple layers (comma-separated) + const layers = splitLayers(value); + + if (layers.length === 1) { + // Single layer - simple case + const { x, y } = parsePosition(value); + return { + "background-position-x": x, + "background-position-y": y, + }; + } + + // Multi-layer - expand each layer and rejoin + const xValues: string[] = []; + const yValues: string[] = []; + + for (const layer of layers) { + const { x, y } = parsePosition(layer); + xValues.push(x); + yValues.push(y); + } + + return { + "background-position-x": xValues.join(", "), + "background-position-y": yValues.join(", "), + }; +} + + +=== File: src/handlers/background-position/index.ts === +// b_path:: src/handlers/background-position/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBackgroundPosition } from "./expand"; + +export const backgroundPositionHandler: PropertyHandler = { + meta: { + shorthand: "background-position", + longhands: ["background-position-x", "background-position-y"], + category: "position", + }, + expand: (value) => expandBackgroundPosition(value), +}; + +export { expandBackgroundPosition }; + + +=== File: src/handlers/background/background-layers.ts === +// b_path:: src/handlers/background/background-layers.ts + +import * as csstree from "@eslint/css-tree"; +import type { BackgroundLayer, BackgroundResult } from "@/core/schema"; +import isColor from "@/internal/is-color"; +import { isPositionValueNode, isSizeValueNode } from "@/internal/is-value-node"; +import { hasTopLevelCommas, splitLayers } from "@/internal/layer-parser-utils"; + +// CSS default values for background properties +export const BACKGROUND_DEFAULTS = { + image: "none", + position: "0% 0%", + size: "auto auto", + repeat: "repeat", + attachment: "scroll", + origin: "padding-box", + clip: "border-box", +} as const; + +/** + * Detects if a background value needs advanced parsing (multi-layer backgrounds) + */ +export function needsAdvancedParser(value: string): boolean { + return hasTopLevelCommas(value); +} + +/** + * Parses a complex background value using css-tree AST parsing + */ +export function parseBackgroundLayers(value: string): BackgroundResult | undefined { + try { + // Split into layers + const layerStrings = splitLayers(value); + if (layerStrings.length === 0) { + return undefined; + } + + // Parse each layer to extract all properties + const parsedLayers: Array = []; + let globalColor: string | undefined; + + for (const layerStr of layerStrings) { + const parsedLayer = parseSingleLayer(layerStr); + + // Extract color from the last layer that has one + if (parsedLayer.color) { + globalColor = parsedLayer.color; + } + + parsedLayers.push(parsedLayer); + } + + // Now distribute properties across layers according to CSS rules + const layers = distributeLayerProperties(parsedLayers); + + return { + layers, + color: globalColor, + }; + } catch (_error) { + // If parsing fails, return undefined to indicate invalid input + return undefined; + } +} + +/** + * Distributes properties across layers according to CSS background rules + */ +function distributeLayerProperties( + parsedLayers: Array +): BackgroundLayer[] { + // For CSS backgrounds, properties are NOT distributed across layers. + // Each layer only gets the properties that were explicitly specified for it. + // Unspecified properties remain undefined and get default values during reconstruction. + + const result: BackgroundLayer[] = []; + + // Just copy the parsed properties - no distribution needed + for (const layer of parsedLayers) { + const { color: _, ...layerProps } = layer; + result.push(layerProps); + } + + // Special handling for origin/clip: if a layer specifies only one box value, + // it applies to both origin and clip + result.forEach((layer) => { + if (layer.origin !== undefined && layer.clip === undefined) { + layer.clip = layer.origin; + } + }); + + return result; +} + +/** + * Parses a single background layer using css-tree AST parsing + */ +function parseSingleLayerWithCssTree(layerValue: string): BackgroundLayer & { color?: string } { + const result: BackgroundLayer & { color?: string } = {}; + + const ast = csstree.parse(layerValue.trim(), { context: "value" }); + + // Collect all child nodes from the Value node + const children: csstree.CssNode[] = []; + csstree.walk(ast, { + visit: "Value", + enter: (node: csstree.CssNode) => { + if (node.type === "Value" && node.children) { + node.children.forEach((child) => { + children.push(child); + }); + } + }, + }); + + // Process children in order, handling position/size parsing + processCssChildren(children, result); + + return result; +} + +/** + * Processes CSS AST children sequentially to extract background properties + * + * This function handles the complex parsing of CSS background layer syntax, + * including position/size combinations separated by "/", various keyword types, + * and proper ordering according to CSS specifications. + */ +function processCssChildren( + children: csstree.CssNode[], + result: BackgroundLayer & { color?: string } +): void { + let i = 0; + let hasPositionSize = false; + + while (i < children.length) { + const child = children[i]; + + // Skip whitespace and operators (except "/") + if (child.type === "WhiteSpace") { + i++; + continue; + } + + if (child.type === "Operator" && (child as csstree.Operator).value !== "/") { + i++; + continue; + } + + // Handle background-image (url(), none, or image functions like gradients) + if (child.type === "Url" && !result.image) { + result.image = `url(${(child as csstree.Url).value})`; + i++; + continue; + } + + if (child.type === "Function") { + const funcNode = child as csstree.FunctionNode; + if ( + [ + "linear-gradient", + "radial-gradient", + "conic-gradient", + "repeating-linear-gradient", + "repeating-radial-gradient", + "repeating-conic-gradient", + "image", + "element", + ].includes(funcNode.name) + ) { + if (!result.image) { + result.image = csstree.generate(child); + } + i++; + continue; + } + } + + if ( + child.type === "Identifier" && + (child as csstree.Identifier).name === "none" && + !result.image + ) { + result.image = "none"; + i++; + continue; + } + + // Handle position and size (complex parsing needed) + if ( + !hasPositionSize && + ((child.type === "Operator" && (child as csstree.Operator).value === "/") || + isPositionValueNode(child, ["left", "center", "right", "top", "bottom"])) + ) { + const positionParts: string[] = []; + const sizeParts: string[] = []; + let _hasSlash = false; + + // Check if we start with "/" + if (child.type === "Operator" && (child as csstree.Operator).value === "/") { + _hasSlash = true; + i++; // skip "/" + + // Collect size parts + while (i < children.length) { + const currentChild = children[i]; + if (currentChild.type === "WhiteSpace") { + i++; + continue; + } + if (isSizeValueNode(currentChild, ["auto", "cover", "contain"])) { + sizeParts.push(csstree.generate(currentChild)); + i++; + } else { + break; + } + } + } else { + // Collect position parts until we hit "/" or a non-position node + while (i < children.length) { + const currentChild = children[i]; + if (currentChild.type === "WhiteSpace") { + i++; + continue; + } + + if ( + currentChild.type === "Operator" && + (currentChild as csstree.Operator).value === "/" + ) { + _hasSlash = true; + i++; // skip "/" + + // Collect size parts + while (i < children.length) { + const sizeChild = children[i]; + if (sizeChild.type === "WhiteSpace") { + i++; + continue; + } + if (isSizeValueNode(sizeChild, ["auto", "cover", "contain"])) { + sizeParts.push(csstree.generate(sizeChild)); + i++; + } else { + break; + } + } + break; + } else if ( + isPositionValueNode(currentChild, ["left", "center", "right", "top", "bottom"]) + ) { + positionParts.push(csstree.generate(currentChild)); + i++; + } else { + break; + } + } + } + + if (positionParts.length > 0) { + result.position = positionParts.join(" "); + } + if (sizeParts.length > 0) { + result.size = sizeParts.join(" "); + } + + hasPositionSize = true; + continue; + } + + // Handle repeat values + if (child.type === "Identifier") { + const name = (child as csstree.Identifier).name; + if (["repeat", "repeat-x", "repeat-y", "space", "round", "no-repeat"].includes(name)) { + if (!result.repeat) { + let repeat = name; + i++; + + // Check for second repeat value + if (i < children.length && children[i].type === "Identifier") { + const nextName = (children[i] as csstree.Identifier).name; + if ( + ["repeat", "repeat-x", "repeat-y", "space", "round", "no-repeat"].includes(nextName) + ) { + repeat += ` ${nextName}`; + i++; + } + } + + result.repeat = repeat; + } else { + i++; + } + continue; + } + } + + // Handle attachment + if (child.type === "Identifier") { + const name = (child as csstree.Identifier).name; + if (["fixed", "local", "scroll"].includes(name)) { + if (!result.attachment) { + result.attachment = name; + } + i++; + continue; + } + } + + // Handle box values (origin/clip) + if (child.type === "Identifier") { + const name = (child as csstree.Identifier).name; + if (["border-box", "padding-box", "content-box"].includes(name)) { + if (!result.origin) { + result.origin = name; + } else if (!result.clip) { + result.clip = name; + } + i++; + continue; + } + } + + // Handle colors + if (child.type === "Identifier" || child.type === "Function" || child.type === "Hash") { + const value = csstree.generate(child); + if (isColor(value)) { + result.color = value; + i++; + continue; + } + } + + // Skip unrecognized nodes + i++; + } +} + +/** + * Parses a single background layer using css-tree AST parsing + * + * This function now uses css-tree for robust CSS parsing instead of + * the previous custom tokenizer approach. + */ +function parseSingleLayer(layerValue: string): BackgroundLayer & { color?: string } { + return parseSingleLayerWithCssTree(layerValue); +} + +/** + * Distributes property values across layers according to CSS rules + */ +export function distributeProperties( + layers: BackgroundLayer[], + properties: Record +): BackgroundLayer[] { + const result = layers.map((layer) => ({ ...layer })); + + // Apply property distribution for each property type + Object.entries(properties).forEach(([property, values]) => { + const distributedValues = distributeValues(values, layers.length); + + distributedValues.forEach((value, index) => { + if (result[index]) { + (result[index] as BackgroundLayer)[property as keyof BackgroundLayer] = value; + } + }); + }); + + return result; +} + +/** + * Distributes values across layers using CSS repetition rules + */ +export function distributeValues(values: string[], layerCount: number): string[] { + if (values.length === 0) return []; + + const result: string[] = []; + + for (let i = 0; i < layerCount; i++) { + // CSS rule: repeat values cyclically if fewer than layers + result.push(values[i % values.length]); + } + + return result; +} + +/** + * Reconstructs final CSS properties from layer objects + */ +export function reconstructLayers( + layers: BackgroundLayer[], + color?: string +): Record { + const result: Record = {}; + + // Collect all layer values for each property + const properties = { + "background-image": layers.map((l) => l.image || BACKGROUND_DEFAULTS.image), + "background-position": layers.map((l) => l.position || BACKGROUND_DEFAULTS.position), + "background-size": layers.map((l) => l.size || BACKGROUND_DEFAULTS.size), + "background-repeat": layers.map((l) => l.repeat || BACKGROUND_DEFAULTS.repeat), + "background-attachment": layers.map((l) => l.attachment || BACKGROUND_DEFAULTS.attachment), + "background-origin": layers.map((l) => l.origin || BACKGROUND_DEFAULTS.origin), + "background-clip": layers.map((l) => l.clip || BACKGROUND_DEFAULTS.clip), + }; + + // Join layer values with commas + Object.entries(properties).forEach(([property, values]) => { + result[property] = values.join(", "); + }); + + // Add color (defaults to transparent if not specified) + result["background-color"] = color || "transparent"; + + return result; +} + + +=== File: src/handlers/background/expand.ts === +// b_path:: src/handlers/background/expand.ts + +// NOTE: This handler contains complex multi-layer parsing logic that is a candidate +// for future refactoring. The position/size parsing could be simplified with better +// abstractions for coordinate and dimension handling. + +import { cssUrlRegex } from "@/internal/color-utils"; +import isColor from "@/internal/is-color"; +import isLength from "@/internal/is-length"; +import normalizeColor from "@/internal/normalize-color"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; +import { parseBackgroundLayers, reconstructLayers } from "./background-layers"; + +const ATTACHMENT = /^(fixed|local|scroll)$/; +const BOX = /^(border-box|padding-box|content-box)$/; +const IMAGE = new RegExp(`^(none|${cssUrlRegex().source})$`, "i"); +const REPEAT_SINGLE = /^(repeat-x|repeat-y)$/i; +const REPEAT_DOUBLE = /^(repeat|space|round|no-repeat)$/i; +const POSITION_HORIZONTAL = /^(left|center|right)$/; +const POSITION_VERTICAL = /^(top|center|bottom)$/; +const SIZE_SINGLE = /^(cover|contain)$/; +const KEYWORD = /^(inherit|initial)$/i; + +interface BackgroundResult { + attachment?: string; + clip?: string; + image?: string; + repeat?: string; + color?: string; + position?: string; + size?: string; +} + +const normalizeUrl = (value: string): string => + value.replace(cssUrlRegex(), (match: string) => + match.replace(/^url\(\s+/, "url(").replace(/\s+\)$/, ")") + ); + +function parseBackgroundValue(value: string): Record | undefined { + // Use advanced parsing for all cases - it handles both simple and complex syntax better + const layeredResult = parseBackgroundLayers(value); + if (layeredResult) { + return reconstructLayers(layeredResult.layers, layeredResult.color); + } + + // Fallback to simple parsing if advanced parsing fails + return simpleBackgroundParser(value); +} + +function simpleBackgroundParser(value: string): Record | undefined { + // Use existing single-layer parsing logic as fallback + const result: BackgroundResult = {}; + const values = normalizeUrl(normalizeColor(value)) + .replace(/\(.*\/.*\)|(\/)+/g, (match: string, group1: string) => (!group1 ? match : " / ")) + .split(/\s+/); + + const first = values[0]; + + if (values.length === 1 && KEYWORD.test(first)) { + return { + "background-attachment": first, + "background-clip": first, + "background-image": first, + "background-repeat": first, + "background-color": first, + "background-position": first, + "background-size": first, + }; + } + + for (let i = 0; i < values.length; i++) { + let v = values[i]; + + if (ATTACHMENT.test(v)) { + if (result.attachment) return; + result.attachment = v; + } else if (BOX.test(v)) { + if (result.clip) return; + result.clip = v; + } else if (IMAGE.test(v)) { + if (result.image) return; + result.image = v; + } else if (REPEAT_SINGLE.test(v)) { + if (result.repeat) return; + result.repeat = v; + } else if (REPEAT_DOUBLE.test(v)) { + if (result.repeat) return; + + const n = values[i + 1]; + + if (n && REPEAT_DOUBLE.test(n)) { + v += ` ${n}`; + i++; + } + + result.repeat = v; + } else if (isColor(v)) { + if (result.color) return; + result.color = v; + } else if (POSITION_HORIZONTAL.test(v) || POSITION_VERTICAL.test(v) || isLength(v)) { + if (result.position) return; + + const n = values[i + 1]; + const isHorizontal = POSITION_HORIZONTAL.test(v) || isLength(v); + const isVertical = n && (POSITION_VERTICAL.test(n) || isLength(n)); + + if (isHorizontal && isVertical) { + result.position = `${v} ${n}`; + i++; + } else { + result.position = v; + } + + const nextV = values[i + 1]; + + if (nextV === "/") { + i += 2; + const sizeV = values[i]; + + if (SIZE_SINGLE.test(sizeV)) { + result.size = sizeV; + } else if (sizeV === "auto" || isLength(sizeV)) { + let sizeValue = sizeV; + const sizeN = values[i + 1]; + + if (sizeN === "auto" || isLength(sizeN)) { + sizeValue += ` ${sizeN}`; + i++; + } + + result.size = sizeValue; + } else { + return; + } + } + } else { + return; + } + } + + const finalResult: Record = {}; + for (const key in result) { + if (result[key as keyof BackgroundResult]) { + finalResult[`background-${key}`] = result[key as keyof BackgroundResult] as string; + } + } + return finalResult; +} + +/** + * Property handler for the 'background' CSS shorthand property + * + * Expands background into background-image, background-position, background-size, + * background-repeat, background-attachment, background-origin, background-clip, + * and background-color. + * + * @example + * ```typescript + * backgroundHandler.expand('red'); + * backgroundHandler.expand('url(bg.png) center / cover no-repeat'); + * ``` + */ +export const backgroundHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "background", + longhands: [ + "background-image", + "background-position", + "background-size", + "background-repeat", + "background-attachment", + "background-clip", + "background-color", + ], + category: "visual", + }, + + expand: (value: string): Record | undefined => { + return parseBackgroundValue(value); + }, + + validate: (value: string): boolean => { + return backgroundHandler.expand(value) !== undefined; + }, +}); + +export default function background(value: string): Record | undefined { + return backgroundHandler.expand(value); +} + + +=== File: src/handlers/background/index.ts === +// b_path:: src/handlers/background/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/border-block-end/expand.ts === +// b_path:: src/handlers/border-block-end/expand.ts + +import { parseBorderSide } from "../../internal/border-side-parser"; + +export function expandBorderBlockEnd(value: string): Record { + const { width, style, color } = parseBorderSide(value); + + return { + "border-block-end-width": width, + "border-block-end-style": style, + "border-block-end-color": color, + }; +} + + +=== File: src/handlers/border-block-end/index.ts === +// b_path:: src/handlers/border-block-end/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderBlockEnd } from "./expand"; + +export const borderBlockEndHandler: PropertyHandler = { + meta: { + shorthand: "border-block-end", + longhands: ["border-block-end-width", "border-block-end-style", "border-block-end-color"], + category: "border", + }, + expand: (value) => expandBorderBlockEnd(value), +}; + +export { expandBorderBlockEnd }; + + +=== File: src/handlers/border-block-start/expand.ts === +// b_path:: src/handlers/border-block-start/expand.ts + +import { parseBorderSide } from "../../internal/border-side-parser"; + +export function expandBorderBlockStart(value: string): Record { + const { width, style, color } = parseBorderSide(value); + + return { + "border-block-start-width": width, + "border-block-start-style": style, + "border-block-start-color": color, + }; +} + + +=== File: src/handlers/border-block-start/index.ts === +// b_path:: src/handlers/border-block-start/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderBlockStart } from "./expand"; + +export const borderBlockStartHandler: PropertyHandler = { + meta: { + shorthand: "border-block-start", + longhands: ["border-block-start-width", "border-block-start-style", "border-block-start-color"], + category: "border", + }, + expand: (value) => expandBorderBlockStart(value), +}; + +export { expandBorderBlockStart }; + + +=== File: src/handlers/border-block/expand.ts === +// b_path:: src/handlers/border-block/expand.ts + +import { parseBorderSide } from "../../internal/border-side-parser"; + +export function expandBorderBlock(value: string): Record { + const { width, style, color } = parseBorderSide(value); + + return { + "border-block-start-width": width, + "border-block-start-style": style, + "border-block-start-color": color, + "border-block-end-width": width, + "border-block-end-style": style, + "border-block-end-color": color, + }; +} + + +=== File: src/handlers/border-block/index.ts === +// b_path:: src/handlers/border-block/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderBlock } from "./expand"; + +export const borderBlockHandler: PropertyHandler = { + meta: { + shorthand: "border-block", + longhands: [ + "border-block-start-width", + "border-block-start-style", + "border-block-start-color", + "border-block-end-width", + "border-block-end-style", + "border-block-end-color", + ], + category: "border", + }, + expand: (value) => expandBorderBlock(value), +}; + +export { expandBorderBlock }; + + +=== File: src/handlers/border-bottom/expand.ts === +// b_path:: src/handlers/border-bottom/expand.ts + +import { createBorderSideExpander } from "../../internal/border-side-parser"; + +export const expandBorderBottom = createBorderSideExpander("bottom"); + + +=== File: src/handlers/border-bottom/index.ts === +// b_path:: src/handlers/border-bottom/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderBottom } from "./expand"; + +export const borderBottomHandler: PropertyHandler = { + meta: { + shorthand: "border-bottom", + longhands: ["border-bottom-width", "border-bottom-style", "border-bottom-color"], + category: "border", + }, + expand: (value) => expandBorderBottom(value), +}; + +export { expandBorderBottom }; + + +=== File: src/handlers/border-color/expand.ts === +// b_path:: src/handlers/border-color/expand.ts + +import { expandTRBL } from "../../internal/trbl-expander"; + +export function expandBorderColor(value: string): Record { + const { top, right, bottom, left } = expandTRBL(value); + + return { + "border-top-color": top, + "border-right-color": right, + "border-bottom-color": bottom, + "border-left-color": left, + }; +} + + +=== File: src/handlers/border-color/index.ts === +// b_path:: src/handlers/border-color/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderColor } from "./expand"; + +export const borderColorHandler: PropertyHandler = { + meta: { + shorthand: "border-color", + longhands: [ + "border-top-color", + "border-right-color", + "border-bottom-color", + "border-left-color", + ], + category: "border", + }, + expand: (value) => expandBorderColor(value), +}; + +export { expandBorderColor }; + + +=== File: src/handlers/border-image/expand.ts === +// b_path:: src/handlers/border-image/expand.ts + +/** + * Border-image shorthand + * Grammar: <'border-image-source'> || <'border-image-slice'> [ / <'border-image-width'> | / <'border-image-width'>? / <'border-image-outset'> ]? || <'border-image-repeat'> + * + * Components: + * - source: url(), gradient, none + * - slice: 1-4 numbers/percentages + optional 'fill' + * - width: 1-4 length/number/auto (after first /) + * - outset: 1-4 length/number (after second /) + * - repeat: stretch|repeat|round|space (1-2 values) + */ + +const REPEAT_KEYWORDS = new Set(["stretch", "repeat", "round", "space"]); + +export function expandBorderImage(value: string): Record { + const trimmed = value.trim(); + + // Global values + if ( + trimmed === "initial" || + trimmed === "inherit" || + trimmed === "unset" || + trimmed === "revert" || + trimmed === "none" + ) { + const val = trimmed === "none" ? trimmed : trimmed; + return { + "border-image-source": val, + "border-image-slice": val, + "border-image-width": val, + "border-image-outset": val, + "border-image-repeat": val, + }; + } + + let source = "none"; + let slice = "100%"; + let width = "1"; + let outset = "0"; + let repeat = "stretch"; + + // Split by / for slice/width/outset + const slashParts = trimmed.split("/").map((p) => p.trim()); + + // First part contains source, slice, and repeat + const mainPart = slashParts[0]; + const tokens = smartSplit(mainPart); + + const sliceTokens: string[] = []; + const repeatTokens: string[] = []; + + for (const token of tokens) { + // Check if it's a source (url, gradient, image-set) + if ( + token.startsWith("url(") || + token.startsWith("linear-gradient(") || + token.startsWith("radial-gradient(") || + token.startsWith("image-set(") + ) { + source = token; + } + // Check if it's a repeat keyword + else if (REPEAT_KEYWORDS.has(token)) { + repeatTokens.push(token); + } + // Otherwise it's part of slice + else { + sliceTokens.push(token); + } + } + + // Build slice value + if (sliceTokens.length > 0) { + slice = sliceTokens.join(" "); + } + + // Build repeat value + if (repeatTokens.length > 0) { + repeat = repeatTokens.join(" "); + } + + // Handle width (after first /) + if (slashParts.length > 1) { + width = slashParts[1]; + } + + // Handle outset (after second /) + if (slashParts.length > 2) { + outset = slashParts[2]; + } + + return { + "border-image-source": source, + "border-image-slice": slice, + "border-image-width": width, + "border-image-outset": outset, + "border-image-repeat": repeat, + }; +} + +function smartSplit(value: string): string[] { + const result: string[] = []; + let current = ""; + let depth = 0; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + + if (char === "(") { + depth++; + current += char; + } else if (char === ")") { + depth--; + current += char; + } else if (char === " " && depth === 0) { + if (current.trim()) { + result.push(current.trim()); + current = ""; + } + } else { + current += char; + } + } + + if (current.trim()) { + result.push(current.trim()); + } + + return result; +} + + +=== File: src/handlers/border-image/index.ts === +// b_path:: src/handlers/border-image/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderImage } from "./expand"; + +export const borderImageHandler: PropertyHandler = { + meta: { + shorthand: "border-image", + longhands: [ + "border-image-source", + "border-image-slice", + "border-image-width", + "border-image-outset", + "border-image-repeat", + ], + category: "border", + }, + expand: (value) => expandBorderImage(value), +}; + +export { expandBorderImage }; + + +=== File: src/handlers/border-inline-end/expand.ts === +// b_path:: src/handlers/border-inline-end/expand.ts + +import { parseBorderSide } from "../../internal/border-side-parser"; + +export function expandBorderInlineEnd(value: string): Record { + const { width, style, color } = parseBorderSide(value); + + return { + "border-inline-end-width": width, + "border-inline-end-style": style, + "border-inline-end-color": color, + }; +} + + +=== File: src/handlers/border-inline-end/index.ts === +// b_path:: src/handlers/border-inline-end/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderInlineEnd } from "./expand"; + +export const borderInlineEndHandler: PropertyHandler = { + meta: { + shorthand: "border-inline-end", + longhands: ["border-inline-end-width", "border-inline-end-style", "border-inline-end-color"], + category: "border", + }, + expand: (value) => expandBorderInlineEnd(value), +}; + +export { expandBorderInlineEnd }; + + +=== File: src/handlers/border-inline-start/expand.ts === +// b_path:: src/handlers/border-inline-start/expand.ts + +import { parseBorderSide } from "../../internal/border-side-parser"; + +export function expandBorderInlineStart(value: string): Record { + const { width, style, color } = parseBorderSide(value); + + return { + "border-inline-start-width": width, + "border-inline-start-style": style, + "border-inline-start-color": color, + }; +} + + +=== File: src/handlers/border-inline-start/index.ts === +// b_path:: src/handlers/border-inline-start/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderInlineStart } from "./expand"; + +export const borderInlineStartHandler: PropertyHandler = { + meta: { + shorthand: "border-inline-start", + longhands: [ + "border-inline-start-width", + "border-inline-start-style", + "border-inline-start-color", + ], + category: "border", + }, + expand: (value) => expandBorderInlineStart(value), +}; + +export { expandBorderInlineStart }; + + +=== File: src/handlers/border-inline/expand.ts === +// b_path:: src/handlers/border-inline/expand.ts + +import { parseBorderSide } from "../../internal/border-side-parser"; + +export function expandBorderInline(value: string): Record { + const { width, style, color } = parseBorderSide(value); + + return { + "border-inline-start-width": width, + "border-inline-start-style": style, + "border-inline-start-color": color, + "border-inline-end-width": width, + "border-inline-end-style": style, + "border-inline-end-color": color, + }; +} + + +=== File: src/handlers/border-inline/index.ts === +// b_path:: src/handlers/border-inline/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderInline } from "./expand"; + +export const borderInlineHandler: PropertyHandler = { + meta: { + shorthand: "border-inline", + longhands: [ + "border-inline-start-width", + "border-inline-start-style", + "border-inline-start-color", + "border-inline-end-width", + "border-inline-end-style", + "border-inline-end-color", + ], + category: "border", + }, + expand: (value) => expandBorderInline(value), +}; + +export { expandBorderInline }; + + +=== File: src/handlers/border-left/expand.ts === +// b_path:: src/handlers/border-left/expand.ts + +import { createBorderSideExpander } from "../../internal/border-side-parser"; + +export const expandBorderLeft = createBorderSideExpander("left"); + + +=== File: src/handlers/border-left/index.ts === +// b_path:: src/handlers/border-left/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderLeft } from "./expand"; + +export const borderLeftHandler: PropertyHandler = { + meta: { + shorthand: "border-left", + longhands: ["border-left-width", "border-left-style", "border-left-color"], + category: "border", + }, + expand: (value) => expandBorderLeft(value), +}; + +export { expandBorderLeft }; + + +=== File: src/handlers/border-radius/expand.ts === +// b_path:: src/handlers/border-radius/expand.ts + +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; + +/** + * Expand 1-4 values following CSS box model (top-left, top-right, bottom-right, bottom-left) + */ +const expandFourValues = (values: string[]): string[] => { + if (values.length === 1) return [values[0], values[0], values[0], values[0]]; + if (values.length === 2) return [values[0], values[1], values[0], values[1]]; + if (values.length === 3) return [values[0], values[1], values[2], values[1]]; + if (values.length === 4) return values; + return []; // Invalid +}; + +/** + * Property handler for the 'border-radius' CSS shorthand property + * + * Expands border-radius into individual corner radius properties. + * Supports both uniform radii and horizontal/vertical radii with slash syntax. + * + * @example + * ```typescript + * borderRadiusHandler.expand('10px'); // All corners: 10px + * borderRadiusHandler.expand('10px 20px'); // TL/BR: 10px, TR/BL: 20px + * borderRadiusHandler.expand('10px / 20px'); // Horizontal: 10px, Vertical: 20px + * ``` + */ +export const borderRadiusHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "border-radius", + longhands: [ + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-bottom-left-radius", + ], + category: "visual", + }, + + expand: (value: string): Record | undefined => { + // Check if there's a slash separator for horizontal/vertical radii + const slashIndex = value.indexOf("/"); + + if (slashIndex !== -1) { + // Split horizontal and vertical radii + const horizontalPart = value.slice(0, slashIndex).trim(); + const verticalPart = value.slice(slashIndex + 1).trim(); + + const horizontalValues = horizontalPart.split(/\s+/).filter((v) => v); + const verticalValues = verticalPart.split(/\s+/).filter((v) => v); + + // Expand both sets to 4 values + const horizontal = expandFourValues(horizontalValues); + const vertical = expandFourValues(verticalValues); + + if (horizontal.length === 0 || vertical.length === 0) return; + + // Combine into corner-specific values + const corners = ["top-left", "top-right", "bottom-right", "bottom-left"]; + const result: Record = {}; + + for (let i = 0; i < 4; i++) { + result[`border-${corners[i]}-radius`] = `${horizontal[i]} ${vertical[i]}`; + } + + return result; + } + + // No slash - simple case with uniform horizontal and vertical radii + const values = value.split(/\s+/).filter((v) => v); + const expanded = expandFourValues(values); + + if (expanded.length === 0) return; + + const corners = ["top-left", "top-right", "bottom-right", "bottom-left"]; + const result: Record = {}; + + for (let i = 0; i < 4; i++) { + result[`border-${corners[i]}-radius`] = expanded[i]; + } + + return result; + }, + + validate: (value: string): boolean => { + return borderRadiusHandler.expand(value) !== undefined; + }, +}); + +export default (value: string): Record | undefined => { + return borderRadiusHandler.expand(value); +}; + + +=== File: src/handlers/border-radius/index.ts === +// b_path:: src/handlers/border-radius/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/border-right/expand.ts === +// b_path:: src/handlers/border-right/expand.ts + +import { createBorderSideExpander } from "../../internal/border-side-parser"; + +export const expandBorderRight = createBorderSideExpander("right"); + + +=== File: src/handlers/border-right/index.ts === +// b_path:: src/handlers/border-right/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderRight } from "./expand"; + +export const borderRightHandler: PropertyHandler = { + meta: { + shorthand: "border-right", + longhands: ["border-right-width", "border-right-style", "border-right-color"], + category: "border", + }, + expand: (value) => expandBorderRight(value), +}; + +export { expandBorderRight }; + + +=== File: src/handlers/border-style/expand.ts === +// b_path:: src/handlers/border-style/expand.ts + +import { expandTRBL } from "../../internal/trbl-expander"; + +export function expandBorderStyle(value: string): Record { + const { top, right, bottom, left } = expandTRBL(value); + + return { + "border-top-style": top, + "border-right-style": right, + "border-bottom-style": bottom, + "border-left-style": left, + }; +} + + +=== File: src/handlers/border-style/index.ts === +// b_path:: src/handlers/border-style/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderStyle } from "./expand"; + +export const borderStyleHandler: PropertyHandler = { + meta: { + shorthand: "border-style", + longhands: [ + "border-top-style", + "border-right-style", + "border-bottom-style", + "border-left-style", + ], + category: "border", + }, + expand: (value) => expandBorderStyle(value), +}; + +export { expandBorderStyle }; + + +=== File: src/handlers/border-top/expand.ts === +// b_path:: src/handlers/border-top/expand.ts + +import { createBorderSideExpander } from "../../internal/border-side-parser"; + +export const expandBorderTop = createBorderSideExpander("top"); + + +=== File: src/handlers/border-top/index.ts === +// b_path:: src/handlers/border-top/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderTop } from "./expand"; + +export const borderTopHandler: PropertyHandler = { + meta: { + shorthand: "border-top", + longhands: ["border-top-width", "border-top-style", "border-top-color"], + category: "border", + }, + expand: (value) => expandBorderTop(value), +}; + +export { expandBorderTop }; + + +=== File: src/handlers/border-width/expand.ts === +// b_path:: src/handlers/border-width/expand.ts + +import { expandTRBL } from "../../internal/trbl-expander"; + +export function expandBorderWidth(value: string): Record { + const { top, right, bottom, left } = expandTRBL(value); + + return { + "border-top-width": top, + "border-right-width": right, + "border-bottom-width": bottom, + "border-left-width": left, + }; +} + + +=== File: src/handlers/border-width/index.ts === +// b_path:: src/handlers/border-width/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandBorderWidth } from "./expand"; + +export const borderWidthHandler: PropertyHandler = { + meta: { + shorthand: "border-width", + longhands: [ + "border-top-width", + "border-right-width", + "border-bottom-width", + "border-left-width", + ], + category: "border", + }, + expand: (value) => expandBorderWidth(value), +}; + +export { expandBorderWidth }; + + +=== File: src/handlers/border/expand.ts === +// b_path:: src/handlers/border/expand.ts + +// NOTE: This handler contains complex hierarchical logic with sub-handlers that is a +// candidate for future refactoring. The border property expands to multiple directions +// (top/right/bottom/left) and properties (width/style/color), plus a box-sizing edge case. + +import directional from "@/internal/directional"; +import isColor from "@/internal/is-color"; +import isLength from "@/internal/is-length"; +import normalizeColor from "@/internal/normalize-color"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; +import { sortProperties } from "@/internal/property-sorter"; + +const WIDTH = /^(thin|medium|thick)$/; +const STYLE = /^(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/i; +const KEYWORD = /^(inherit|initial|unset|revert)$/i; + +interface BorderProperties { + width?: string; + style?: string; + color?: string; +} + +interface BorderResult extends BorderProperties { + boxSizing?: string; +} + +type BorderFunction = { + (value: string): Record | undefined; + width: (value: string) => Record | undefined; + style: (value: string) => Record | undefined; + color: (value: string) => Record | undefined; + top: (value: string) => Record | undefined; + right: (value: string) => Record | undefined; + bottom: (value: string) => Record | undefined; + left: (value: string) => Record | undefined; +}; + +const suffix = + (suffix: string) => + (value: string): Record | undefined => { + const longhand = directional(value); + + if (!longhand) return; + + const result: Record = {}; + for (const key in longhand) { + result[`border-${key}-${suffix}`] = longhand[key]; + } + return sortProperties(result); + }; + +const direction = + (direction: string) => + (value: string): Record | undefined => { + const longhand = all(value); + + if (!longhand) return; + + const filtered: Record = {}; + for (const key in longhand) { + if (key === "boxSizing" && longhand[key]) { + filtered[key] = longhand[key]; + } else if (longhand[key as keyof BorderProperties]) { + filtered[`border-${direction}-${key}`] = longhand[key as keyof BorderProperties] as string; + } + } + return sortProperties(filtered); + }; + +const all = (value: string): BorderResult | undefined => { + const values = normalizeColor(value).split(/\s+/); + const first = values[0]; + + // Handle special case: border values with box-sizing + if (values.length === 4) { + const [width, style, color, boxSizing] = values; + + // Check if first 3 values are valid border values and 4th is valid box-sizing + if ( + (WIDTH.test(width) || isLength(width)) && + STYLE.test(style) && + isColor(color) && + (boxSizing === "border-box" || boxSizing === "content-box") + ) { + return { + width, + style, + color, + boxSizing, + }; + } + } + + if (values.length > 3) return; + if (values.length === 1 && KEYWORD.test(first)) { + return { + width: first, + style: first, + color: first, + }; + } + + const result: BorderProperties = {}; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + + if (WIDTH.test(v) || isLength(v)) { + if (result.width) return; + result.width = v; + } else if (STYLE.test(v)) { + if (result.style) return; + result.style = v; + } else if (isColor(v)) { + if (result.color) return; + result.color = v; + } else { + return; + } + } + + return result; +}; + +function parseBorderValue(value: string): Record | undefined { + const longhand = all(value); + + if (!longhand) return; + + const result: Record = {}; + + // Handle box-sizing separately + if (longhand.boxSizing) { + result["box-sizing"] = longhand.boxSizing; + } + + // Use defaults for missing properties + // Per CSS spec, the default values for border shorthand are: + // width: 'medium', style: 'none', color: 'currentcolor' + // See: https://drafts.csswg.org/css-backgrounds-3/#border-shorthand + const width = longhand.width || "medium"; + const style = longhand.style || "none"; + const color = longhand.color || "currentcolor"; + + // Expand all three border properties + const widthProps = suffix("width")(width); + const styleProps = suffix("style")(style); + const colorProps = suffix("color")(color); + + if (widthProps) Object.assign(result, widthProps); + if (styleProps) Object.assign(result, styleProps); + if (colorProps) Object.assign(result, colorProps); + + return sortProperties(result); +} + +export const borderHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "border", + longhands: [ + "border-top-width", + "border-right-width", + "border-bottom-width", + "border-left-width", + "border-top-style", + "border-right-style", + "border-bottom-style", + "border-left-style", + "border-top-color", + "border-right-color", + "border-bottom-color", + "border-left-color", + ], + defaults: { + "border-top-width": "medium", + "border-right-width": "medium", + "border-bottom-width": "medium", + "border-left-width": "medium", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "currentcolor", + "border-right-color": "currentcolor", + "border-bottom-color": "currentcolor", + "border-left-color": "currentcolor", + }, + category: "box-model", + }, + + expand: (value: string) => parseBorderValue(value), + + validate: (value: string) => borderHandler.expand(value) !== undefined, +}); + +const border: BorderFunction = (value: string): Record | undefined => { + return borderHandler.expand(value); +}; + +border.width = suffix("width"); +border.style = suffix("style"); +border.color = suffix("color"); +border.top = direction("top"); +border.right = direction("right"); +border.bottom = direction("bottom"); +border.left = direction("left"); + +export default border; + + +=== File: src/handlers/border/index.ts === +// b_path:: src/handlers/border/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/column-rule/expand.ts === +// b_path:: src/handlers/column-rule/expand.ts + +import isColor from "@/internal/is-color"; +import isLength from "@/internal/is-length"; +import normalizeColor from "@/internal/normalize-color"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; +import { sortProperties } from "@/internal/property-sorter"; + +const WIDTH = /^(thin|medium|thick)$/; +const STYLE = /^(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/i; +const KEYWORD = /^(inherit|initial|unset|revert)$/i; + +/** + * Property handler for the 'column-rule' CSS shorthand property + * + * Expands column-rule into column-rule-width, column-rule-style, and column-rule-color. + * + * @example + * ```typescript + * columnRuleHandler.expand('medium'); // { 'column-rule-width': 'medium', 'column-rule-style': 'none', 'column-rule-color': 'currentcolor' } + * columnRuleHandler.expand('3px solid red'); // { 'column-rule-width': '3px', 'column-rule-style': 'solid', 'column-rule-color': 'red' } + * columnRuleHandler.expand('dotted blue'); // { 'column-rule-width': 'medium', 'column-rule-style': 'dotted', 'column-rule-color': 'blue' } + * ``` + */ +export const columnRuleHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "column-rule", + longhands: ["column-rule-width", "column-rule-style", "column-rule-color"], + defaults: { + "column-rule-width": "medium", + "column-rule-style": "none", + "column-rule-color": "currentcolor", + }, + category: "visual", + }, + + expand: (value: string): Record | undefined => { + const values = normalizeColor(value).split(/\s+/); + + if (values.length > 3) return; + if (values.length === 1 && KEYWORD.test(values[0])) { + return sortProperties({ + "column-rule-width": values[0], + "column-rule-style": values[0], + "column-rule-color": values[0], + }); + } + + const parsed: { width?: string; style?: string; color?: string } = {}; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + + if (isLength(v) || WIDTH.test(v)) { + if (parsed.width) return; + parsed.width = v; + } else if (STYLE.test(v)) { + if (parsed.style) return; + parsed.style = v; + } else if (isColor(v)) { + if (parsed.color) return; + parsed.color = v; + } else { + return; + } + } + + // Use defaults for missing properties + // Per CSS spec, the default values for column-rule shorthand are: + // width: 'medium', style: 'none', color: 'currentcolor' + // See: https://drafts.csswg.org/css-multicol-1/#propdef-column-rule + return sortProperties({ + "column-rule-width": parsed.width || "medium", + "column-rule-style": parsed.style || "none", + "column-rule-color": parsed.color || "currentcolor", + }); + }, + + validate: (value: string): boolean => { + return columnRuleHandler.expand(value) !== undefined; + }, +}); + +// Export default for backward compatibility with existing code +export default function columnRule(value: string): Record | undefined { + return columnRuleHandler.expand(value); +} + + +=== File: src/handlers/column-rule/index.ts === +// b_path:: src/handlers/column-rule/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/columns/expand.ts === +// b_path:: src/handlers/columns/expand.ts + +import isLength from "@/internal/is-length"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; +import { sortProperties } from "@/internal/property-sorter"; + +/** + * Property handler for the 'columns' CSS shorthand property + * + * Expands columns into column-width and column-count. + * + * @example + * ```typescript + * columnsHandler.expand('auto'); // { 'column-width': 'auto', 'column-count': 'auto' } + * columnsHandler.expand('12em'); // { 'column-width': '12em', 'column-count': 'auto' } + * columnsHandler.expand('auto 12'); // { 'column-width': 'auto', 'column-count': '12' } + * columnsHandler.expand('12em 5'); // { 'column-width': '12em', 'column-count': '5' } + * ``` + */ +export const columnsHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "columns", + longhands: ["column-width", "column-count"], + category: "layout", + }, + + expand: (value: string): Record | undefined => { + // Handle global CSS keywords + if (/^(inherit|initial|unset|revert)$/i.test(value)) { + return sortProperties({ + "column-width": value, + "column-count": value, + }); + } + + // Split values on whitespace + const values = value.trim().split(/\s+/); + + // Validate value count - max 2 values + if (values.length > 2) { + return undefined; + } + + // Regex patterns for type detection + const INTEGER = /^[0-9]+$/; + const KEYWORD = /^(auto)$/i; + + const result: Record = {}; + + // Separate specific values from auto values + const specificValues: Array<{ value: string; type: "width" | "count" }> = []; + const autoValues: string[] = []; + + for (const val of values) { + if (KEYWORD.test(val)) { + autoValues.push(val); + } else if (isLength(val)) { + specificValues.push({ value: val, type: "width" }); + } else if (INTEGER.test(val)) { + specificValues.push({ value: val, type: "count" }); + } else { + return undefined; // Invalid value + } + } + + // Check for conflicts in specific values + if ( + specificValues.filter((v) => v.type === "width").length > 1 || + specificValues.filter((v) => v.type === "count").length > 1 + ) { + return undefined; // Multiple values for same property + } + + // Assign specific values first + for (const { value, type } of specificValues) { + result[`column-${type}`] = value; + } + + // Assign auto values to remaining properties + for (const autoValue of autoValues) { + if (!result["column-width"]) { + result["column-width"] = autoValue; + } else if (!result["column-count"]) { + result["column-count"] = autoValue; + } else { + return undefined; // No available property for auto + } + } + + return sortProperties(result); + }, + + validate: (value: string): boolean => { + return columnsHandler.expand(value) !== undefined; + }, +}); + +// Export default for backward compatibility with existing code +export default (value: string): Record | undefined => { + return columnsHandler.expand(value); +}; + + +=== File: src/handlers/columns/index.ts === +// b_path:: src/handlers/columns/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/contain-intrinsic-size/expand.ts === +// b_path:: src/handlers/contain-intrinsic-size/expand.ts + +import isLength from "@/internal/is-length"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; + +/** + * Property handler for the 'contain-intrinsic-size' CSS shorthand property + * + * Expands contain-intrinsic-size into contain-intrinsic-width and contain-intrinsic-height. + * + * @example + * ```typescript + * containIntrinsicSizeHandler.expand('auto 100px'); // { 'contain-intrinsic-width': 'auto 100px', 'contain-intrinsic-height': 'auto 100px' } + * containIntrinsicSizeHandler.expand('100px auto 200px'); // { 'contain-intrinsic-width': '100px', 'contain-intrinsic-height': 'auto 200px' } + * containIntrinsicSizeHandler.expand('none'); // { 'contain-intrinsic-width': 'none', 'contain-intrinsic-height': 'none' } + * ``` + */ +export const containIntrinsicSizeHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "contain-intrinsic-size", + longhands: ["contain-intrinsic-width", "contain-intrinsic-height"], + category: "layout", + }, + + expand: (value: string): Record | undefined => { + // Handle global CSS keywords + if (/^(inherit|initial|unset|revert)$/i.test(value)) { + return { + "contain-intrinsic-width": value, + "contain-intrinsic-height": value, + }; + } + + // Split values on whitespace + const tokens = value.trim().split(/\s+/); + + // Validate token count - max 4 tokens (for two auto pairs) + if (tokens.length > 4 || tokens.length === 0) { + return undefined; + } + + const result: Record = {}; + + // Parse tokens into width and height values + let i = 0; + const parseValue = (): string | undefined => { + if (i >= tokens.length) return undefined; + + const token = tokens[i++]; + if (token.toLowerCase() === "auto") { + if (i >= tokens.length) return undefined; // auto must be followed by something + const nextToken = tokens[i++]; + if (nextToken.toLowerCase() === "none") { + return "auto none"; + } else if (isLength(nextToken)) { + return `auto ${nextToken}`; + } else { + return undefined; // invalid after auto + } + } else if (token.toLowerCase() === "none") { + return "none"; + } else if (isLength(token)) { + return token; + } else { + return undefined; // invalid token + } + }; + + // Parse width value + const widthValue = parseValue(); + if (widthValue === undefined) return undefined; + + // Parse height value (if present) + const heightValue = parseValue(); + + // If only one value provided, apply to both + if (heightValue === undefined) { + result["contain-intrinsic-width"] = widthValue; + result["contain-intrinsic-height"] = widthValue; + } else { + // Two values provided + result["contain-intrinsic-width"] = widthValue; + result["contain-intrinsic-height"] = heightValue; + } + + // Ensure no extra tokens + if (i !== tokens.length) return undefined; + + return result; + }, + + validate: (value: string): boolean => { + return containIntrinsicSizeHandler.expand(value) !== undefined; + }, +}); + +// Export default for backward compatibility with existing code +export default (value: string): Record | undefined => { + return containIntrinsicSizeHandler.expand(value); +}; + + +=== File: src/handlers/contain-intrinsic-size/index.ts === +// b_path:: src/handlers/contain-intrinsic-size/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/container/expand.ts === +// b_path:: src/handlers/container/expand.ts + +export function expandContainer(value: string): Record { + const trimmed = value.trim(); + + // Global values + if ( + trimmed === "initial" || + trimmed === "inherit" || + trimmed === "unset" || + trimmed === "revert" + ) { + return { + "container-name": trimmed, + "container-type": trimmed, + }; + } + + // Split on / + const parts = trimmed.split("/").map((p) => p.trim()); + + if (parts.length === 1) { + // Just name, or just type if it's a type keyword + const val = parts[0]; + if (val === "none" || val === "size" || val === "inline-size" || val === "normal") { + return { + "container-name": "none", + "container-type": val, + }; + } + return { + "container-name": val, + "container-type": "normal", + }; + } + + // name / type + return { + "container-name": parts[0], + "container-type": parts[1], + }; +} + + +=== File: src/handlers/container/index.ts === +// b_path:: src/handlers/container/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandContainer } from "./expand"; + +export const containerHandler: PropertyHandler = { + meta: { + shorthand: "container", + longhands: ["container-name", "container-type"], + category: "layout", + }, + expand: (value) => expandContainer(value), +}; + +export { expandContainer }; + + +=== File: src/handlers/flex-flow/expand.ts === +// b_path:: src/handlers/flex-flow/expand.ts +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; + +/** + * Property handler for the 'flex-flow' CSS shorthand property + * + * Expands flex-flow into flex-direction and flex-wrap. + * + * @example + * ```typescript + * flexFlowHandler.expand('row wrap'); // { 'flex-direction': 'row', 'flex-wrap': 'wrap' } + * flexFlowHandler.expand('column'); // { 'flex-direction': 'column' } + * ``` + */ +export const flexFlowHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "flex-flow", + longhands: ["flex-direction", "flex-wrap"], + defaults: { + "flex-direction": "row", + "flex-wrap": "nowrap", + }, + category: "layout", + }, + + expand: (value: string): Record | undefined => { + // Handle global CSS keywords first + if (/^(inherit|initial|unset|revert)$/i.test(value)) { + return { + "flex-direction": value, + "flex-wrap": value, + }; + } + + // Parse normal values + const parts = value.trim().split(/\s+/); + if (parts.length > 2) return undefined; + + // Define keyword patterns + const directionPattern = /^(row|row-reverse|column|column-reverse)$/i; + const wrapPattern = /^(nowrap|wrap|wrap-reverse)$/i; + + // Value classification logic + let direction: string | undefined; + let wrap: string | undefined; + + for (const part of parts) { + if (directionPattern.test(part)) { + if (direction !== undefined) return undefined; // duplicate + direction = part; + } else if (wrapPattern.test(part)) { + if (wrap !== undefined) return undefined; // duplicate + wrap = part; + } else { + return undefined; // invalid + } + } + + // Return result + const result: Record = {}; + if (direction) result["flex-direction"] = direction; + if (wrap) result["flex-wrap"] = wrap; + return Object.keys(result).length > 0 ? result : undefined; + }, + + validate: (value: string): boolean => { + const result = flexFlowHandler.expand(value); + return result !== undefined; + }, +}); + +// Export default for backward compatibility with existing code +export default function (value: string): Record | undefined { + return flexFlowHandler.expand(value); +} + + +=== File: src/handlers/flex-flow/index.ts === +// b_path:: src/handlers/flex-flow/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/flex/expand.ts === +// b_path:: src/handlers/flex/expand.ts + +import isLength from "@/internal/is-length"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; + +/** + * Property handler for the 'flex' CSS shorthand property + * + * Expands flex into flex-grow, flex-shrink, and flex-basis. + * + * @example + * ```typescript + * flexHandler.expand('1'); // { 'flex-grow': '1', 'flex-shrink': '1', 'flex-basis': '0%' } + * flexHandler.expand('auto'); // { 'flex-grow': '1', 'flex-shrink': '1', 'flex-basis': 'auto' } + * flexHandler.expand('1 1 200px'); // { 'flex-grow': '1', 'flex-shrink': '1', 'flex-basis': '200px' } + * ``` + */ +export const flexHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "flex", + longhands: ["flex-grow", "flex-shrink", "flex-basis"], + defaults: { + "flex-grow": "0", + "flex-shrink": "1", + "flex-basis": "auto", + }, + category: "layout", + }, + + expand: (value: string): Record | undefined => { + // Handle global CSS keywords + if (/^(inherit|unset|revert)$/i.test(value)) { + return { + "flex-grow": value, + "flex-shrink": value, + "flex-basis": value, + }; + } + + // Special case for initial + if (value === "initial") { + return { + "flex-grow": "0", + "flex-shrink": "1", + "flex-basis": "auto", + }; + } + + // Handle special keyword values + if (value === "none") { + return { + "flex-grow": "0", + "flex-shrink": "0", + "flex-basis": "auto", + }; + } + + if (value === "auto") { + return { + "flex-grow": "1", + "flex-shrink": "1", + "flex-basis": "auto", + }; + } + + // Parse normal values + const parts = value.trim().split(/\s+/); + if (parts.length > 3 || parts.length === 0) return undefined; + + // Define value type detection patterns + const numberPattern = /^[+-]?([0-9]*\.)?[0-9]+$/; + const flexBasisKeywordPattern = /^(auto|content|max-content|min-content|fit-content)$/i; + const fitContentFn = /^fit-content\(\s*[^)]+\s*\)$/i; + + // Classify each value by type + const classified: Array<{ value: string; type: "number" | "basis" }> = []; + for (const part of parts) { + if (numberPattern.test(part)) { + classified.push({ value: part, type: "number" }); + } else if (flexBasisKeywordPattern.test(part) || isLength(part) || fitContentFn.test(part)) { + classified.push({ value: part, type: "basis" }); + } else { + return undefined; + } + } + + // Handle unitless zero as basis in three-value form + if ( + classified.length === 3 && + classified[2].type === "number" && + isLength(classified[2].value) + ) { + classified[2] = { type: "basis", value: classified[2].value }; + } + + // Apply expansion rules based on value count + if (classified.length === 1) { + const [val] = classified; + if (val.type === "number") { + return { + "flex-grow": val.value, + "flex-shrink": "1", + "flex-basis": "0%", + }; + } else { + return { + "flex-grow": "1", + "flex-shrink": "1", + "flex-basis": val.value, + }; + } + } else if (classified.length === 2) { + const [first, second] = classified; + if (first.type === "number" && second.type === "number") { + return { + "flex-grow": first.value, + "flex-shrink": second.value, + "flex-basis": "0%", + }; + } else if (first.type === "number" && second.type === "basis") { + return { + "flex-grow": first.value, + "flex-shrink": "1", + "flex-basis": second.value, + }; + } else if (first.type === "basis" && second.type === "number") { + return { + "flex-grow": second.value, + "flex-shrink": "1", + "flex-basis": first.value, + }; + } else { + return undefined; + } + } else if (classified.length === 3) { + const [first, second, third] = classified; + if (first.type === "number" && second.type === "number" && third.type === "basis") { + return { + "flex-grow": first.value, + "flex-shrink": second.value, + "flex-basis": third.value, + }; + } else { + return undefined; + } + } + + return undefined; + }, + + validate: (value: string): boolean => { + return flexHandler.expand(value) !== undefined; + }, +}); + +export default function (value: string): Record | undefined { + return flexHandler.expand(value); +} + + +=== File: src/handlers/flex/index.ts === +// b_path:: src/handlers/flex/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/font/expand.ts === +// b_path:: src/handlers/font/expand.ts + +// NOTE: This handler contains complex state machine parsing logic from css-font-parser +// that is a candidate for future refactoring. The order-dependent parsing and font-family +// handling with quotes and commas adds significant complexity. + +/** + * Copied from https://github.com/bramstein/css-font-parser + */ + +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; +import { sortProperties } from "@/internal/property-sorter"; + +/** + * @enum {number} + */ +const states = { + VARIATION: 1, + LINE_HEIGHT: 2, + FONT_FAMILY: 3, +}; + +interface FontResult { + "font-family": string[]; + "font-size"?: string; + "line-height"?: string; + "font-style"?: string; + "font-weight"?: string; + "font-variant"?: string; + "font-stretch"?: string; +} + +function parse(input: string): FontResult | null { + let state = states.VARIATION; + let buffer = ""; + const result: FontResult = { + "font-family": [], + }; + + for (let i = 0; i < input.length; i += 1) { + const c = input.charAt(i); + if (state === states.FONT_FAMILY && (c === '"' || c === "'")) { + let index = i + 1; + + // consume the entire string + do { + index = input.indexOf(c, index) + 1; + if (!index) { + // If a string is not closed by a ' or " return null. + // TODO: Check to see if this is correct. + return null; + } + } while (input.charAt(index - 2) === "\\"); + + result["font-family"].push(input.slice(i + 1, index - 1).replace(/\\('|")/g, "$1")); + + i = index - 1; + buffer = ""; + } else if (state === states.FONT_FAMILY && c === ",") { + if (!/^\s*$/.test(buffer)) { + result["font-family"].push(buffer.replace(/^\s+|\s+$/, "").replace(/\s+/g, " ")); + buffer = ""; + } + } else if (state === states.VARIATION && (c === " " || c === "/")) { + if ( + /^((xx|x)-large|(xx|s)-small|small|large|medium)$/.test(buffer) || + /^(larg|small)er$/.test(buffer) || + /^(\+|-)?([0-9]*\.)?[0-9]+(em|ex|ch|rem|vh|vw|vmin|vmax|px|mm|cm|in|pt|pc|%)$/.test(buffer) + ) { + state = c === "/" ? states.LINE_HEIGHT : states.FONT_FAMILY; + result["font-size"] = buffer; + } else if (/^(italic|oblique)$/.test(buffer)) { + result["font-style"] = buffer; + } else if (/^small-caps$/.test(buffer)) { + result["font-variant"] = buffer; + } else if (/^(bold(er)?|lighter|normal|[1-9]00)$/.test(buffer)) { + result["font-weight"] = buffer; + } else if (/^((ultra|extra|semi)-)?(condensed|expanded)$/.test(buffer)) { + result["font-stretch"] = buffer; + } + buffer = ""; + } else if (state === states.LINE_HEIGHT && c === " ") { + if ( + /^(\+|-)?([0-9]*\.)?[0-9]+(em|ex|ch|rem|vh|vw|vmin|vmax|px|mm|cm|in|pt|pc|%)?$/.test(buffer) + ) { + result["line-height"] = buffer; + } + state = states.FONT_FAMILY; + buffer = ""; + } else { + buffer += c; + } + } + + if (state === states.FONT_FAMILY && !/^\s*$/.test(buffer)) { + result["font-family"].push(buffer.replace(/^\s+|\s+$/, "").replace(/\s+/g, " ")); + } + + if (result["font-size"] && result["font-family"].length) { + return result; + } else { + return null; + } +} + +function parseFontValue(input: string): Record | undefined { + if (/^(inherit|initial)$/.test(input)) { + return sortProperties({ + "font-size": input, + "line-height": input, + "font-style": input, + "font-weight": input, + "font-variant": input, + "font-stretch": input, + "font-family": input, + }); + } + + input = input.replace(/\s*\/\s*/, "/"); + const result = parse(input); + + if (result) { + const finalResult: Record = {}; + + // Set defaults for properties that should always be present + // Per CSS spec, font shorthand resets these to initial values if not specified + const defaults = { + "font-style": "normal", + "font-variant": "normal", + "font-weight": "normal", + "font-stretch": "normal", + }; + + // Apply defaults first + Object.assign(finalResult, defaults); + + // Then override with parsed values + for (const key in result) { + if (key === "font-family") { + finalResult[key] = result[key] + .map((family: string) => + /^(serif|sans-serif|monospace|cursive|fantasy)$/.test(family) ? family : `"${family}"` + ) + .join(", "); + } else if (result[key as keyof FontResult]) { + finalResult[key] = result[key as keyof FontResult] as string; + } + } + return sortProperties(finalResult); + } + + return undefined; +} + +/** + * Property handler for the 'font' CSS shorthand property + * + * Expands font into font-style, font-variant, font-weight, font-stretch, + * font-size, line-height, and font-family. + * + * @example + * ```typescript + * fontHandler.expand('italic bold 16px/1.5 Arial, sans-serif'); + * fontHandler.expand('12px "Helvetica Neue", Helvetica'); + * ``` + */ +export const fontHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "font", + longhands: [ + "font-style", + "font-variant", + "font-weight", + "font-stretch", + "font-size", + "line-height", + "font-family", + ], + category: "typography", + }, + + expand: (value: string): Record | undefined => { + return parseFontValue(value); + }, + + validate: (value: string): boolean => { + return fontHandler.expand(value) !== undefined; + }, +}); + +export default function font(value: string): Record | undefined { + return fontHandler.expand(value); +} + + +=== File: src/handlers/font/index.ts === +// b_path:: src/handlers/font/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/gap/expand.ts === +// b_path:: src/handlers/gap/expand.ts + +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; +import { sortProperties } from "@/internal/property-sorter"; + +/** + * Parses gap shorthand value + * Syntax: gap = <'row-gap'> <'column-gap'>? + * + * If one value: both row-gap and column-gap use that value + * If two values: first is row-gap, second is column-gap + */ +function parseGapValue(value: string): Record | undefined { + const trimmed = value.trim(); + + // Handle CSS-wide keywords + if (/^(inherit|initial|unset|revert)$/i.test(trimmed)) { + return sortProperties({ + "row-gap": trimmed, + "column-gap": trimmed, + }); + } + + // Split by whitespace + const parts = trimmed.split(/\s+/); + + if (parts.length === 0 || parts.length > 2) { + return undefined; + } + + if (parts.length === 1) { + // Single value - applies to both + return sortProperties({ + "row-gap": parts[0], + "column-gap": parts[0], + }); + } + + // Two values - row then column + return sortProperties({ + "row-gap": parts[0], + "column-gap": parts[1], + }); +} + +/** + * Property handler for the 'gap' CSS shorthand property + * + * Expands gap into row-gap and column-gap. + * + * @example + * ```typescript + * gapHandler.expand('10px'); // row-gap: 10px; column-gap: 10px + * gapHandler.expand('10px 20px'); // row-gap: 10px; column-gap: 20px + * gapHandler.expand('normal'); // row-gap: normal; column-gap: normal + * ``` + */ +export const gapHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "gap", + longhands: ["row-gap", "column-gap"], + category: "layout", + }, + + expand: (value: string): Record | undefined => { + return parseGapValue(value); + }, + + validate: (value: string): boolean => { + return gapHandler.expand(value) !== undefined; + }, +}); + +export default function gap(value: string): Record | undefined { + return gapHandler.expand(value); +} + + +=== File: src/handlers/gap/index.ts === +// b_path:: src/handlers/gap/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/grid-area/expand.ts === +// b_path:: src/handlers/grid-area/expand.ts + +import { getDefaultEnd, parseGridLine } from "@/internal/grid-line"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; + +/** + * Property handler for the 'grid-area' CSS shorthand property + * + * Expands grid-area into grid-row-start, grid-column-start, grid-row-end, and grid-column-end. + * + * @example + * ```typescript + * gridAreaHandler.expand('header'); // Named grid area + * gridAreaHandler.expand('1 / 2'); // { 'grid-row-start': '1', 'grid-column-start': '2', ... } + * gridAreaHandler.expand('1 / 2 / 3 / 4'); // All four values specified + * ``` + */ +export const gridAreaHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "grid-area", + longhands: ["grid-row-start", "grid-column-start", "grid-row-end", "grid-column-end"], + category: "layout", + }, + + expand: (value: string): Record | undefined => { + // Handle global CSS keywords + if (/^(inherit|initial|unset|revert)$/i.test(value)) { + return { + "grid-row-start": value, + "grid-column-start": value, + "grid-row-end": value, + "grid-column-end": value, + }; + } + + // Split values on slash + const parts = value.trim().split(/\s*\/\s*/); + + // Validate part count - max 4 parts + if (parts.length > 4) { + return undefined; + } + + // Validate all parts + for (const part of parts) { + if (!parseGridLine(part.trim())) { + return undefined; + } + } + + let rowStart: string, columnStart: string, rowEnd: string, columnEnd: string; + + if (parts.length === 1) { + // 1 value: row-start (or all if custom-ident) + const val = parts[0].trim(); + if (/^[a-zA-Z_-][a-zA-Z0-9_-]*$/.test(val) && !/^(auto|span|\d)/i.test(val)) { + // Custom-ident: all four get the same value + rowStart = columnStart = rowEnd = columnEnd = val; + } else { + // Otherwise: row-start gets value, others auto + rowStart = val; + columnStart = "auto"; + rowEnd = "auto"; + columnEnd = "auto"; + } + } else if (parts.length === 2) { + // 2 values: row-start / column-start + rowStart = parts[0].trim(); + columnStart = parts[1].trim(); + rowEnd = getDefaultEnd(rowStart); + columnEnd = getDefaultEnd(columnStart); + } else if (parts.length === 3) { + // 3 values: row-start / column-start / row-end + rowStart = parts[0].trim(); + columnStart = parts[1].trim(); + rowEnd = parts[2].trim(); + columnEnd = getDefaultEnd(columnStart); + } else { + // 4 values: row-start / column-start / row-end / column-end + rowStart = parts[0].trim(); + columnStart = parts[1].trim(); + rowEnd = parts[2].trim(); + columnEnd = parts[3].trim(); + } + + return { + "grid-row-start": rowStart, + "grid-column-start": columnStart, + "grid-row-end": rowEnd, + "grid-column-end": columnEnd, + }; + }, + + validate: (value: string): boolean => { + return gridAreaHandler.expand(value) !== undefined; + }, +}); + +export default (value: string): Record | undefined => { + return gridAreaHandler.expand(value); +}; + + +=== File: src/handlers/grid-area/index.ts === +// b_path:: src/handlers/grid-area/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/grid-column/expand.ts === +// b_path:: src/handlers/grid-column/expand.ts + +import { getDefaultEnd, parseGridLine } from "@/internal/grid-line"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; + +/** + * Property handler for the 'grid-column' CSS shorthand property + * + * Expands grid-column into grid-column-start and grid-column-end. + * + * @example + * ```typescript + * gridColumnHandler.expand('2'); // { 'grid-column-start': '2', 'grid-column-end': 'auto' } + * gridColumnHandler.expand('2 / 4'); // { 'grid-column-start': '2', 'grid-column-end': '4' } + * gridColumnHandler.expand('span 3'); // { 'grid-column-start': 'span 3', 'grid-column-end': 'auto' } + * ``` + */ +export const gridColumnHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "grid-column", + longhands: ["grid-column-start", "grid-column-end"], + category: "layout", + }, + + expand: (value: string): Record | undefined => { + // Handle global CSS keywords + if (/^(inherit|initial|unset|revert)$/i.test(value)) { + return { + "grid-column-start": value, + "grid-column-end": value, + }; + } + + // Split values on slash + const parts = value.trim().split(/\s*\/\s*/); + + // Validate part count - max 2 parts + if (parts.length > 2) { + return undefined; + } + + // Handle single value + if (parts.length === 1) { + const startValue = parts[0].trim(); + if (!parseGridLine(startValue)) { + return undefined; + } + const endValue = getDefaultEnd(startValue); + return { + "grid-column-start": startValue, + "grid-column-end": endValue, + }; + } + + // Handle two values + if (parts.length === 2) { + const startValue = parts[0].trim(); + const endValue = parts[1].trim(); + if (!parseGridLine(startValue) || !parseGridLine(endValue)) { + return undefined; + } + return { + "grid-column-start": startValue, + "grid-column-end": endValue, + }; + } + + return undefined; + }, + + validate: (value: string): boolean => { + return gridColumnHandler.expand(value) !== undefined; + }, +}); + +export default (value: string): Record | undefined => { + return gridColumnHandler.expand(value); +}; + + +=== File: src/handlers/grid-column/index.ts === +// b_path:: src/handlers/grid-column/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/grid-row/expand.ts === +// b_path:: src/handlers/grid-row/expand.ts + +import { getDefaultEnd, parseGridLine } from "@/internal/grid-line"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; + +/** + * Property handler for the 'grid-row' CSS shorthand property + * + * Expands grid-row into grid-row-start and grid-row-end. + * + * @example + * ```typescript + * gridRowHandler.expand('2'); // { 'grid-row-start': '2', 'grid-row-end': 'auto' } + * gridRowHandler.expand('2 / 4'); // { 'grid-row-start': '2', 'grid-row-end': '4' } + * gridRowHandler.expand('span 3'); // { 'grid-row-start': 'span 3', 'grid-row-end': 'auto' } + * ``` + */ +export const gridRowHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "grid-row", + longhands: ["grid-row-start", "grid-row-end"], + category: "layout", + }, + + expand: (value: string): Record | undefined => { + // Handle global CSS keywords + if (/^(inherit|initial|unset|revert)$/i.test(value)) { + return { + "grid-row-start": value, + "grid-row-end": value, + }; + } + + // Split values on slash + const parts = value.trim().split(/\s*\/\s*/); + + // Validate part count - max 2 parts + if (parts.length > 2) { + return undefined; + } + + // Handle single value + if (parts.length === 1) { + const startValue = parts[0].trim(); + if (!parseGridLine(startValue)) { + return undefined; + } + const endValue = getDefaultEnd(startValue); + return { + "grid-row-start": startValue, + "grid-row-end": endValue, + }; + } + + // Handle two values + if (parts.length === 2) { + const startValue = parts[0].trim(); + const endValue = parts[1].trim(); + if (!parseGridLine(startValue) || !parseGridLine(endValue)) { + return undefined; + } + return { + "grid-row-start": startValue, + "grid-row-end": endValue, + }; + } + + return undefined; + }, + + validate: (value: string): boolean => { + return gridRowHandler.expand(value) !== undefined; + }, +}); + +export default (value: string): Record | undefined => { + return gridRowHandler.expand(value); +}; + + +=== File: src/handlers/grid-row/index.ts === +// b_path:: src/handlers/grid-row/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/grid-template/expand.ts === +// b_path:: src/handlers/grid-template/expand.ts + +/** + * Grid-template is complex - supports multiple syntaxes: + * 1. none + * 2. grid-template-rows / grid-template-columns + * 3. [ line-names ] "grid-area-strings" track-size [ line-names ] + * + * For v2.0.0, we'll handle the common cases correctly + */ + +export function expandGridTemplate(value: string): Record { + const trimmed = value.trim(); + + // Global values + if ( + trimmed === "initial" || + trimmed === "inherit" || + trimmed === "unset" || + trimmed === "revert" || + trimmed === "none" + ) { + return { + "grid-template-rows": trimmed, + "grid-template-columns": trimmed, + "grid-template-areas": trimmed === "none" ? "none" : trimmed, + }; + } + + // Check for / separator (rows / columns syntax) + if (trimmed.includes(" / ")) { + const [rows, columns] = trimmed.split(" / ").map((p) => p.trim()); + return { + "grid-template-rows": rows, + "grid-template-columns": columns, + "grid-template-areas": "none", + }; + } + + // Check for grid-area syntax (contains quotes) + if (trimmed.includes('"') || trimmed.includes("'")) { + // Complex grid-areas syntax + // Extract area strings and optional track sizes + const areaMatches = trimmed.match(/["'][^"']+["']/g); + if (areaMatches) { + const areas = areaMatches.join(" "); + // Remove area strings to get track sizes + let remaining = trimmed; + for (const area of areaMatches) { + remaining = remaining.replace(area, ""); + } + const trackSize = remaining.trim() || "auto"; + + return { + "grid-template-rows": trackSize, + "grid-template-columns": "none", + "grid-template-areas": areas, + }; + } + } + + // Fallback: treat as rows + return { + "grid-template-rows": trimmed, + "grid-template-columns": "none", + "grid-template-areas": "none", + }; +} + + +=== File: src/handlers/grid-template/index.ts === +// b_path:: src/handlers/grid-template/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandGridTemplate } from "./expand"; + +export const gridTemplateHandler: PropertyHandler = { + meta: { + shorthand: "grid-template", + longhands: ["grid-template-rows", "grid-template-columns", "grid-template-areas"], + category: "grid", + }, + expand: (value) => expandGridTemplate(value), +}; + +export { expandGridTemplate }; + + +=== File: src/handlers/grid/expand.ts === +// b_path:: src/handlers/grid/expand.ts + +// NOTE: This handler contains extremely complex grid template syntax parsing logic +// (~446 lines) that is a candidate for future refactoring. The implementation handles +// named grid lines, track sizes, repeat() notation, area names, and multiple syntaxes +// (template form, explicit-rows, explicit-columns). The parsing logic is preserved as-is. + +import * as csstree from "@eslint/css-tree"; +import { matchesType } from "@/internal/is-value-node"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; + +// CSS default values for grid properties +// NOTE: row-gap and column-gap are NOT part of grid shorthand +export const GRID_DEFAULTS = { + "grid-template-rows": "none", + "grid-template-columns": "none", + "grid-template-areas": "none", + "grid-auto-rows": "auto", + "grid-auto-columns": "auto", + "grid-auto-flow": "row", +} as const; + +/** + * Parses value and returns segments and slash count + */ +function parseValueAndGetSegments( + value: string +): { leftSegment: csstree.CssNode[]; rightSegment: csstree.CssNode[]; slashCount: number } | null { + try { + const ast = csstree.parse(value.trim(), { context: "value" }) as csstree.Value; + + // Find top-level / operator positions + const slashIndices: number[] = []; + + csstree.walk(ast, { + visit: "Value", + enter: (node: csstree.CssNode) => { + if (node.type === "Value" && node.children) { + let index = 0; + node.children.forEach((child) => { + if (child.type === "Operator" && (child as csstree.Operator).value === "/") { + slashIndices.push(index); + } + index++; + }); + } + }, + }); + + const slashCount = slashIndices.length; + if (slashCount > 1) { + return null; // Multiple slashes not allowed + } + + const childrenArray = listToArray(ast.children); + if (slashCount === 0) { + return { leftSegment: childrenArray, rightSegment: [], slashCount: 0 }; + } + + const slashIndex = slashIndices[0]; + const leftSegment = childrenArray.slice(0, slashIndex); + const rightSegment = childrenArray.slice(slashIndex + 1); + + return { leftSegment, rightSegment, slashCount }; + } catch { + return null; + } +} + +/** + * Detects which grid shorthand form is being used + */ +function detectGridForm( + leftSegment: csstree.CssNode[], + rightSegment: csstree.CssNode[] +): "template" | "explicit-rows" | "explicit-columns" | null { + if (rightSegment.length === 0) { + // No slash, check if it's valid track list or ASCII art + return "template"; + } + + // Check for auto-flow in each segment + let hasAutoFlowLeft = false; + let hasAutoFlowRight = false; + + for (const node of leftSegment) { + if (node.type === "Identifier" && (node as csstree.Identifier).name === "auto-flow") { + hasAutoFlowLeft = true; + break; + } + } + + for (const node of rightSegment) { + if (node.type === "Identifier" && (node as csstree.Identifier).name === "auto-flow") { + hasAutoFlowRight = true; + break; + } + } + + if (hasAutoFlowRight) { + return "explicit-rows"; + } else if (hasAutoFlowLeft) { + return "explicit-columns"; + } else { + return "template"; + } +} + +/** + * Helper to convert List to array + */ +function listToArray(list: csstree.List): T[] { + const arr: T[] = []; + for (const item of list) { + arr.push(item); + } + return arr; +} + +/** + * Separates areas (strings) and tracks (other nodes) from a segment + */ +function separateAreasAndTracks(segmentNodes: csstree.CssNode[]): { + areas: string | undefined; + tracks: string | undefined; +} { + const strings: string[] = []; + const tracks: csstree.CssNode[] = []; + + for (const node of segmentNodes) { + if (node.type === "String") { + strings.push((node as csstree.StringNode).value); + } else if (node.type !== "WhiteSpace") { + tracks.push(node); + } + } + + let areas: string | undefined; + if (strings.length > 0) { + // Validate that all rows have the same number of columns + const rows = strings.map((s) => s.trim().split(/\s+/)); + const columnCount = rows[0]?.length || 0; + if (columnCount === 0 || !rows.every((row) => row.length === columnCount)) { + return { areas: undefined, tracks: undefined }; // Invalid + } + areas = strings.map((s) => `"${s.trim()}"`).join(" "); + } + + let tracksStr: string | undefined; + if (tracks.length > 0) { + tracksStr = parseTrackList(tracks); + } + + return { areas, tracks: tracksStr }; +} + +/** + * Parses track list (rows or columns) into CSS string + */ +function parseTrackList(segmentNodes: csstree.CssNode[]): string | undefined { + const validNodes: csstree.CssNode[] = []; + + for (const node of segmentNodes) { + if ( + node.type === "Identifier" && + ["auto", "min-content", "max-content"].includes((node as csstree.Identifier).name) + ) { + validNodes.push(node); + } else if (matchesType(node, ["Dimension", "Percentage", "Number"])) { + validNodes.push(node); + } else if ( + node.type === "Function" && + ["repeat", "minmax", "fit-content"].includes((node as csstree.FunctionNode).name) + ) { + validNodes.push(node); + } else if (node.type === "Parentheses") { + // Named grid lines like [line1] + validNodes.push(node); + } + } + + if (validNodes.length === 0) { + return undefined; + } + + // Generate CSS from the valid nodes + const generatedParts: string[] = []; + for (const node of validNodes) { + generatedParts.push(csstree.generate(node)); + } + + return generatedParts.join(" "); +} + +/** + * Parses template form: + */ +function parseTemplateForm( + leftSegment: csstree.CssNode[], + rightSegment: csstree.CssNode[] +): Record | undefined { + let templateAreas: string | undefined; + let templateRows: string | undefined; + let templateColumns: string | undefined; + + if (rightSegment.length === 0) { + // No slash - could be just rows, but not areas + const { areas, tracks } = separateAreasAndTracks(leftSegment); + if (areas) { + return undefined; // Strings without slash not supported + } + templateRows = tracks; + if (!templateRows) { + return undefined; // Invalid + } + } else { + const { areas: leftAreas, tracks: leftTracks } = separateAreasAndTracks(leftSegment); + const { tracks: rightTracks } = separateAreasAndTracks(rightSegment); + + // Template form logic: + // - If left has areas, then left areas + left tracks (as rows) + right columns + // - If left has tracks but no areas, then left rows + right columns + // - If no slash and left has areas, then just areas + // - If no slash and left has tracks, then just rows + + if (leftAreas) { + templateAreas = leftAreas; + templateRows = leftTracks; // Interleaved track sizes become rows + templateColumns = rightTracks; + if (!templateColumns) { + return undefined; + } + } else if (leftTracks) { + templateRows = leftTracks; + templateColumns = rightTracks; + if (!templateColumns) { + return undefined; + } + } else { + return undefined; // Invalid + } + } + + return { + "grid-template-rows": templateRows || GRID_DEFAULTS["grid-template-rows"], + "grid-template-columns": templateColumns || GRID_DEFAULTS["grid-template-columns"], + "grid-template-areas": templateAreas || GRID_DEFAULTS["grid-template-areas"], + "grid-auto-rows": GRID_DEFAULTS["grid-auto-rows"], + "grid-auto-columns": GRID_DEFAULTS["grid-auto-columns"], + "grid-auto-flow": GRID_DEFAULTS["grid-auto-flow"], + }; +} + +/** + * Parses explicit rows form: / auto-flow [dense] [] + */ +function parseExplicitRowsForm( + leftSegment: csstree.CssNode[], + rightSegment: csstree.CssNode[] +): Record | undefined { + // Check if right segment starts with auto-flow + let firstNonWhiteSpace: csstree.CssNode | undefined; + for (const node of rightSegment) { + if (node.type !== "WhiteSpace") { + firstNonWhiteSpace = node; + break; + } + } + if ( + !firstNonWhiteSpace || + firstNonWhiteSpace.type !== "Identifier" || + (firstNonWhiteSpace as csstree.Identifier).name !== "auto-flow" + ) { + return undefined; + } + + // Left side: template rows + const templateRows = parseTrackList(leftSegment); + if (!templateRows) { + return undefined; + } + + // Right side: auto-flow [dense] [auto-columns] + let autoFlow = "column"; + let autoColumns: string | undefined; + + // Find auto-flow and dense + let hasDense = false; + let autoColumnsStart = -1; + + for (let i = 0; i < rightSegment.length; i++) { + const node = rightSegment[i]; + if (node.type === "Identifier" && (node as csstree.Identifier).name === "auto-flow") { + // Next might be dense + if ( + i + 1 < rightSegment.length && + rightSegment[i + 1].type === "Identifier" && + (rightSegment[i + 1] as csstree.Identifier).name === "dense" + ) { + hasDense = true; + autoColumnsStart = i + 2; + } else { + autoColumnsStart = i + 1; + } + break; + } + } + + if (hasDense) { + autoFlow = "column dense"; + } + + // Parse auto-columns if present + if (autoColumnsStart >= 0 && autoColumnsStart < rightSegment.length) { + const autoColumnsSegment = rightSegment.slice(autoColumnsStart); + autoColumns = parseTrackList(autoColumnsSegment); + } + + return { + "grid-template-rows": templateRows, + "grid-template-columns": GRID_DEFAULTS["grid-template-columns"], + "grid-template-areas": GRID_DEFAULTS["grid-template-areas"], + "grid-auto-rows": GRID_DEFAULTS["grid-auto-rows"], + "grid-auto-columns": autoColumns || GRID_DEFAULTS["grid-auto-columns"], + "grid-auto-flow": autoFlow, + }; +} + +/** + * Parses explicit columns form: auto-flow [dense] [] / + */ +function parseExplicitColumnsForm( + leftSegment: csstree.CssNode[], + rightSegment: csstree.CssNode[] +): Record | undefined { + // Check if left segment starts with auto-flow + let firstNonWhiteSpace: csstree.CssNode | undefined; + for (const node of leftSegment) { + if (node.type !== "WhiteSpace") { + firstNonWhiteSpace = node; + break; + } + } + if ( + !firstNonWhiteSpace || + firstNonWhiteSpace.type !== "Identifier" || + (firstNonWhiteSpace as csstree.Identifier).name !== "auto-flow" + ) { + return undefined; + } + + // Right side: template columns + const templateColumns = parseTrackList(rightSegment); + if (!templateColumns) { + return undefined; + } + + // Left side: auto-flow [dense] [auto-rows] + let autoFlow = "row"; + let autoRows: string | undefined; + + // Find auto-flow and dense + let hasDense = false; + let autoRowsStart = -1; + + for (let i = 0; i < leftSegment.length; i++) { + const node = leftSegment[i]; + if (node.type === "Identifier" && (node as csstree.Identifier).name === "auto-flow") { + // Next might be dense + if ( + i + 1 < leftSegment.length && + leftSegment[i + 1].type === "Identifier" && + (leftSegment[i + 1] as csstree.Identifier).name === "dense" + ) { + hasDense = true; + autoRowsStart = i + 2; + } else { + autoRowsStart = i + 1; + } + break; + } + } + + if (hasDense) { + autoFlow = "row dense"; + } + + // Parse auto-rows if present + if (autoRowsStart >= 0 && autoRowsStart < leftSegment.length) { + const autoRowsSegment = leftSegment.slice(autoRowsStart); + autoRows = parseTrackList(autoRowsSegment); + } + + return { + "grid-template-rows": GRID_DEFAULTS["grid-template-rows"], + "grid-template-columns": templateColumns, + "grid-template-areas": GRID_DEFAULTS["grid-template-areas"], + "grid-auto-rows": autoRows || GRID_DEFAULTS["grid-auto-rows"], + "grid-auto-columns": GRID_DEFAULTS["grid-auto-columns"], + "grid-auto-flow": autoFlow, + }; +} + +function parseGridValue(value: string): Record | undefined { + // Handle global CSS keywords + if (/^(inherit|initial|unset|revert)$/i.test(value)) { + return { + "grid-template-rows": value, + "grid-template-columns": value, + "grid-template-areas": value, + "grid-auto-rows": value, + "grid-auto-columns": value, + "grid-auto-flow": value, + }; + } + + // Handle none keyword + if (value.trim().toLowerCase() === "none") { + return { ...GRID_DEFAULTS }; + } + + // Parse value and get segments + const segments = parseValueAndGetSegments(value); + if (!segments) { + return undefined; // Invalid (e.g., multiple slashes) + } + + // Detect form and parse accordingly + const form = detectGridForm(segments.leftSegment, segments.rightSegment); + switch (form) { + case "template": + return parseTemplateForm(segments.leftSegment, segments.rightSegment); + case "explicit-rows": + return parseExplicitRowsForm(segments.leftSegment, segments.rightSegment); + case "explicit-columns": + return parseExplicitColumnsForm(segments.leftSegment, segments.rightSegment); + default: + return undefined; + } +} + +export const gridHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "grid", + longhands: [ + "grid-template-rows", + "grid-template-columns", + "grid-template-areas", + "grid-auto-rows", + "grid-auto-columns", + "grid-auto-flow", + // NOTE: row-gap and column-gap are NOT part of grid shorthand + // They have their own 'gap' shorthand + ], + defaults: GRID_DEFAULTS, + category: "layout", + }, + + expand: (value: string) => parseGridValue(value), + + validate: (value: string) => gridHandler.expand(value) !== undefined, +}); + +export default (value: string): Record | undefined => { + return gridHandler.expand(value); +}; + + +=== File: src/handlers/grid/index.ts === +// b_path:: src/handlers/grid/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/inset-block/expand.ts === +// b_path:: src/handlers/inset-block/expand.ts + +export function expandInsetBlock(value: string): Record { + const trimmed = value.trim(); + + // Global values + if ( + trimmed === "initial" || + trimmed === "inherit" || + trimmed === "unset" || + trimmed === "revert" + ) { + return { + "inset-block-start": trimmed, + "inset-block-end": trimmed, + }; + } + + const parts = trimmed.split(/\s+/).filter((p) => p); + + if (parts.length === 1) { + return { + "inset-block-start": parts[0], + "inset-block-end": parts[0], + }; + } + + // 2 values: start end + return { + "inset-block-start": parts[0], + "inset-block-end": parts[1], + }; +} + + +=== File: src/handlers/inset-block/index.ts === +// b_path:: src/handlers/inset-block/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandInsetBlock } from "./expand"; + +export const insetBlockHandler: PropertyHandler = { + meta: { + shorthand: "inset-block", + longhands: ["inset-block-start", "inset-block-end"], + category: "positioning", + }, + expand: (value) => expandInsetBlock(value), +}; + +export { expandInsetBlock }; + + +=== File: src/handlers/inset-inline/expand.ts === +// b_path:: src/handlers/inset-inline/expand.ts + +export function expandInsetInline(value: string): Record { + const trimmed = value.trim(); + + // Global values + if ( + trimmed === "initial" || + trimmed === "inherit" || + trimmed === "unset" || + trimmed === "revert" + ) { + return { + "inset-inline-start": trimmed, + "inset-inline-end": trimmed, + }; + } + + const parts = trimmed.split(/\s+/).filter((p) => p); + + if (parts.length === 1) { + return { + "inset-inline-start": parts[0], + "inset-inline-end": parts[0], + }; + } + + // 2 values: start end + return { + "inset-inline-start": parts[0], + "inset-inline-end": parts[1], + }; +} + + +=== File: src/handlers/inset-inline/index.ts === +// b_path:: src/handlers/inset-inline/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandInsetInline } from "./expand"; + +export const insetInlineHandler: PropertyHandler = { + meta: { + shorthand: "inset-inline", + longhands: ["inset-inline-start", "inset-inline-end"], + category: "positioning", + }, + expand: (value) => expandInsetInline(value), +}; + +export { expandInsetInline }; + + +=== File: src/handlers/inset/expand.ts === +// b_path:: src/handlers/inset/expand.ts + +import { expandTRBL } from "../../internal/trbl-expander"; + +export function expandInset(value: string): Record { + const { top, right, bottom, left } = expandTRBL(value); + + return { + top, + right, + bottom, + left, + }; +} + + +=== File: src/handlers/inset/index.ts === +// b_path:: src/handlers/inset/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandInset } from "./expand"; + +export const insetHandler: PropertyHandler = { + meta: { + shorthand: "inset", + longhands: ["top", "right", "bottom", "left"], + category: "positioning", + }, + expand: (value) => expandInset(value), +}; + +export { expandInset }; + + +=== File: src/handlers/list-style/expand.ts === +// b_path:: src/handlers/list-style/expand.ts + +import { cssUrlRegex } from "@/internal/color-utils"; +import normalizeColor from "@/internal/normalize-color"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; +import { sortProperties } from "@/internal/property-sorter"; + +const KEYWORD = /^(inherit|initial|unset|revert)$/i; +const POSITION = /^(inside|outside)$/i; +const IMAGE = new RegExp(`^(${cssUrlRegex().source})$`, "i"); +const COMMON_TYPE = + /^(disc|circle|square|decimal|decimal-leading-zero|lower-roman|upper-roman|lower-greek|lower-alpha|lower-latin|upper-alpha|upper-latin|armenian|georgian|none)$/i; +const IDENT = /^[-_a-zA-Z][-_a-zA-Z0-9]*$/; +const STRING_VALUE = /^["'].*["']$/; + +/** + * Property handler for the 'list-style' CSS shorthand property + * + * Expands list-style into list-style-type, list-style-position, and list-style-image. + * + * @example + * ```typescript + * listStyleHandler.expand('none'); // { 'list-style-type': 'none', 'list-style-position': 'outside', 'list-style-image': 'none' } + * listStyleHandler.expand('disc inside'); // { 'list-style-type': 'disc', 'list-style-position': 'inside', 'list-style-image': 'none' } + * listStyleHandler.expand('url(bullet.png)'); // { 'list-style-type': 'disc', 'list-style-position': 'outside', 'list-style-image': 'url(bullet.png)' } + * ``` + */ +export const listStyleHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "list-style", + longhands: ["list-style-type", "list-style-position", "list-style-image"], + defaults: { + "list-style-type": "disc", + "list-style-position": "outside", + "list-style-image": "none", + }, + category: "typography", + }, + + expand: (value: string): Record | undefined => { + const normalizedValue = normalizeColor(value); + + // Special case: "none" alone sets both type and image to none, position to default + if (normalizedValue === "none") { + return sortProperties({ + "list-style-type": "none", + "list-style-position": "outside", + "list-style-image": "none", + }); + } + + const values = normalizedValue.split(/\s+/); + + if (values.length === 1 && KEYWORD.test(values[0])) { + return sortProperties({ + "list-style-type": values[0], + "list-style-position": values[0], + "list-style-image": values[0], + }); + } + + // Start with defaults - list-style shorthand resets all properties + const result: Record = { + "list-style-type": "disc", + "list-style-position": "outside", + "list-style-image": "none", + }; + + // Track what was explicitly set to detect duplicates + const explicitlySet = { + type: false, + position: false, + image: false, + }; + + for (let i = 0; i < values.length; i++) { + const v = values[i]; + + if (POSITION.test(v)) { + if (explicitlySet.position) return; + result["list-style-position"] = v; + explicitlySet.position = true; + } else if (IMAGE.test(v)) { + if (explicitlySet.image) return; + result["list-style-image"] = v; + explicitlySet.image = true; + } else if (COMMON_TYPE.test(v)) { + if (explicitlySet.type) return; + result["list-style-type"] = v; + explicitlySet.type = true; + } else { + // Custom counter-style identifier or string value + if (IDENT.test(v) || STRING_VALUE.test(v)) { + if (explicitlySet.type) return; + result["list-style-type"] = v; + explicitlySet.type = true; + } else { + return; + } + } + } + + return sortProperties(result); + }, + + validate: (value: string): boolean => { + return listStyleHandler.expand(value) !== undefined; + }, +}); + +// Export default for backward compatibility with existing code +export default function listStyle(value: string): Record | undefined { + return listStyleHandler.expand(value); +} + + +=== File: src/handlers/list-style/index.ts === +// b_path:: src/handlers/list-style/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/margin/expand.ts === +// b_path:: src/handlers/margin/expand.ts + +import { createTRBLExpander } from "../../internal/trbl-expander"; + +export const expandMargin = createTRBLExpander("margin"); + + +=== File: src/handlers/margin/index.ts === +// b_path:: src/handlers/margin/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandMargin } from "./expand"; + +export const marginHandler: PropertyHandler = { + meta: { + shorthand: "margin", + longhands: ["margin-top", "margin-right", "margin-bottom", "margin-left"], + category: "box-model", + }, + expand: (value) => expandMargin(value), +}; + +export { expandMargin }; + + +=== File: src/handlers/mask-position/expand.ts === +// b_path:: src/handlers/mask-position/expand.ts + +import { parsePosition } from "../../internal/position-parser"; + +export function expandMaskPosition(value: string): Record { + const { x, y } = parsePosition(value); + + return { + "mask-position-x": x, + "mask-position-y": y, + }; +} + + +=== File: src/handlers/mask-position/index.ts === +// b_path:: src/handlers/mask-position/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandMaskPosition } from "./expand"; + +export const maskPositionHandler: PropertyHandler = { + meta: { + shorthand: "mask-position", + longhands: ["mask-position-x", "mask-position-y"], + category: "position", + }, + expand: (value) => expandMaskPosition(value), +}; + +export { expandMaskPosition }; + + +=== File: src/handlers/mask/expand.ts === +// b_path:: src/handlers/mask/expand.ts + +// NOTE: This handler contains complex multi-layer parsing logic that is a candidate +// for future refactoring. Masking syntax parsing could be simplified with better abstractions. + +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; +import { needsAdvancedParser, parseMaskLayers, reconstructLayers } from "./mask-layers"; + +const KEYWORD = /^(inherit|initial|unset|revert)$/i; + +function parseMaskValue(value: string): Record | undefined { + // Trim the input value + const trimmedValue = value.trim(); + + // Handle global keywords first + if (KEYWORD.test(trimmedValue)) { + return { + "mask-image": trimmedValue, + "mask-mode": trimmedValue, + "mask-position": trimmedValue, + "mask-size": trimmedValue, + "mask-repeat": trimmedValue, + "mask-origin": trimmedValue, + "mask-clip": trimmedValue, + "mask-composite": trimmedValue, + }; + } + + // Check for multi-layer syntax + if (needsAdvancedParser(trimmedValue)) { + const result = parseMaskLayers(trimmedValue); + if (result) { + return reconstructLayers(result.layers); + } + return undefined; + } + + // For single-layer cases, use the advanced parser as well + // since mask syntax is complex and the parser handles it well + const result = parseMaskLayers(trimmedValue); + if (result) { + return reconstructLayers(result.layers); + } + + return undefined; +} + +/** + * Property handler for the 'mask' CSS shorthand property + * + * Expands mask into mask-image, mask-mode, mask-position, mask-size, + * mask-repeat, mask-origin, mask-clip, and mask-composite. + * + * @example + * ```typescript + * maskHandler.expand('url(mask.svg)'); + * maskHandler.expand('linear-gradient(black, transparent) center / contain'); + * ``` + */ +export const maskHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "mask", + longhands: [ + "mask-image", + "mask-mode", + "mask-position", + "mask-size", + "mask-repeat", + "mask-origin", + "mask-clip", + "mask-composite", + ], + category: "visual", + }, + + expand: (value: string): Record | undefined => { + return parseMaskValue(value); + }, + + validate: (value: string): boolean => { + return maskHandler.expand(value) !== undefined; + }, +}); + +export default function mask(value: string): Record | undefined { + return maskHandler.expand(value); +} + + +=== File: src/handlers/mask/index.ts === +// b_path:: src/handlers/mask/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/mask/mask-layers.ts === +// b_path:: src/handlers/mask/mask-layers.ts + +import * as csstree from "@eslint/css-tree"; +import type { MaskLayer, MaskResult } from "@/core/schema"; +import { isPositionValueNode, isSizeValueNode } from "@/internal/is-value-node"; +import { hasTopLevelCommas, parseLayersGeneric } from "@/internal/layer-parser-utils"; + +// CSS default values for mask properties +export const MASK_DEFAULTS = { + image: "none", + mode: "match-source", + position: "0% 0%", + size: "auto", + repeat: "repeat", + origin: "border-box", + clip: "border-box", + composite: "add", +} as const; + +/** + * Detects if a mask value needs advanced parsing (multi-layer masks) + */ +export function needsAdvancedParser(value: string): boolean { + return hasTopLevelCommas(value); +} + +/** + * Parses a complex mask value using css-tree AST parsing + */ +export function parseMaskLayers(value: string): MaskResult | undefined { + const layers = parseLayersGeneric(value, parseSingleLayer); + return layers ? { layers } : undefined; +} + +/** + * Parses a single mask layer using css-tree AST parsing + */ +function parseSingleLayer(layerValue: string): MaskLayer | undefined { + return parseSingleLayerWithCssTree(layerValue); +} + +/** + * Parses a single mask layer using css-tree AST parsing + */ +function parseSingleLayerWithCssTree(layerValue: string): MaskLayer | undefined { + const result: MaskLayer = {}; + + const ast = csstree.parse(layerValue.trim(), { context: "value" }); + + // Collect all child nodes from the Value node + const children: csstree.CssNode[] = []; + csstree.walk(ast, { + visit: "Value", + enter: (node: csstree.CssNode) => { + if (node.type === "Value" && node.children) { + node.children.forEach((child) => { + children.push(child); + }); + } + }, + }); + + // Process children in order, handling position/size parsing + const isValid = processCssChildren(children, result); + + // Return undefined if there were unrecognized tokens (stricter error handling) + return isValid ? result : undefined; +} + +/** + * Processes CSS AST children sequentially to extract mask properties + * + * This function handles the complex parsing of CSS mask layer syntax, + * including position/size combinations separated by "/", various keyword types, + * and proper ordering according to CSS specifications. + */ +function processCssChildren(children: csstree.CssNode[], result: MaskLayer): boolean { + let i = 0; + let hasPositionSize = false; + let hasUnrecognizedToken = false; + + while (i < children.length) { + const child = children[i]; + + // Skip whitespace and operators (except "/") + if (child.type === "WhiteSpace") { + i++; + continue; + } + + if (child.type === "Operator" && (child as csstree.Operator).value !== "/") { + i++; + continue; + } + + // Handle mask-image (url(), none, or image functions like gradients) + if (child.type === "Url" && !result.image) { + result.image = `url(${(child as csstree.Url).value})`; + i++; + continue; + } + + if (child.type === "Function") { + const funcNode = child as csstree.FunctionNode; + if ( + [ + "linear-gradient", + "radial-gradient", + "conic-gradient", + "repeating-linear-gradient", + "repeating-radial-gradient", + "repeating-conic-gradient", + "image", + "image-set", + "cross-fade", + "paint", + "element", + ].includes(funcNode.name) + ) { + if (!result.image) { + result.image = csstree.generate(child); + } + i++; + continue; + } + } + + if ( + child.type === "Identifier" && + (child as csstree.Identifier).name === "none" && + !result.image + ) { + result.image = "none"; + i++; + continue; + } + + // Handle position and size (complex parsing needed) + if ( + !hasPositionSize && + ((child.type === "Operator" && (child as csstree.Operator).value === "/") || + isPositionValueNode(child, ["left", "center", "right", "top", "bottom"])) + ) { + const positionParts: string[] = []; + const sizeParts: string[] = []; + let _hasSlash = false; + + // Check if we start with "/" + if (child.type === "Operator" && (child as csstree.Operator).value === "/") { + _hasSlash = true; + i++; // skip "/" + + // Collect size parts + while (i < children.length) { + const currentChild = children[i]; + if (currentChild.type === "WhiteSpace") { + i++; + continue; + } + if (isSizeValueNode(currentChild, ["auto", "cover", "contain"])) { + sizeParts.push(csstree.generate(currentChild)); + i++; + } else { + break; + } + } + } else { + // Collect position parts until we hit "/" or a non-position node + while (i < children.length) { + const currentChild = children[i]; + if (currentChild.type === "WhiteSpace") { + i++; + continue; + } + + if ( + currentChild.type === "Operator" && + (currentChild as csstree.Operator).value === "/" + ) { + _hasSlash = true; + i++; // skip "/" + + // Collect size parts + while (i < children.length) { + const sizeChild = children[i]; + if (sizeChild.type === "WhiteSpace") { + i++; + continue; + } + if (isSizeValueNode(sizeChild, ["auto", "cover", "contain"])) { + sizeParts.push(csstree.generate(sizeChild)); + i++; + } else { + break; + } + } + break; + } else if ( + isPositionValueNode(currentChild, ["left", "center", "right", "top", "bottom"]) + ) { + positionParts.push(csstree.generate(currentChild)); + i++; + } else { + break; + } + } + } + + if (positionParts.length > 0) { + result.position = positionParts.join(" "); + } + if (sizeParts.length > 0) { + result.size = sizeParts.join(" "); + } + + hasPositionSize = true; + continue; + } + + // Handle repeat values + // Note: repeat-x and repeat-y are supported for compatibility with background-repeat, + // even though the mask-repeat spec technically only allows [repeat|no-repeat|round|space]{1,2} + if (child.type === "Identifier") { + const name = (child as csstree.Identifier).name; + if (["repeat", "repeat-x", "repeat-y", "space", "round", "no-repeat"].includes(name)) { + if (!result.repeat) { + let repeat = name; + i++; + + // Check for second repeat value + if (i < children.length && children[i].type === "Identifier") { + const nextName = (children[i] as csstree.Identifier).name; + if ( + ["repeat", "repeat-x", "repeat-y", "space", "round", "no-repeat"].includes(nextName) + ) { + repeat += ` ${nextName}`; + i++; + } + } + + result.repeat = repeat; + } else { + i++; + } + continue; + } + } + + // Handle mode keywords + if (child.type === "Identifier") { + const name = (child as csstree.Identifier).name; + if (["alpha", "luminance", "match-source"].includes(name)) { + if (!result.mode) { + result.mode = name; + } + i++; + continue; + } + } + + // Handle composite keywords + if (child.type === "Identifier") { + const name = (child as csstree.Identifier).name; + if (["add", "subtract", "intersect", "exclude"].includes(name)) { + if (!result.composite) { + result.composite = name; + } + i++; + continue; + } + } + + // Handle geometry-box values (origin/clip) + if (child.type === "Identifier") { + const name = (child as csstree.Identifier).name; + if ( + ["border-box", "padding-box", "content-box", "fill-box", "stroke-box", "view-box"].includes( + name + ) + ) { + if (!result.origin) { + result.origin = name; + } else if (!result.clip) { + result.clip = name; + } + i++; + continue; + } + } + + // Handle "no-clip" keyword for mask-clip + if (child.type === "Identifier") { + const name = (child as csstree.Identifier).name; + if (name === "no-clip") { + if (!result.clip) { + result.clip = "no-clip"; + } + i++; + continue; + } + } + + // Skip unrecognized nodes - mark as having unrecognized token for stricter error handling + hasUnrecognizedToken = true; + i++; + } + + // Special handling for origin/clip: if a layer specifies only one box value, + // it applies to both origin and clip (CSS Masking spec behavior) + if (result.origin !== undefined && result.clip === undefined) { + result.clip = MASK_DEFAULTS.clip; // Default to border-box, not origin value + } + + return !hasUnrecognizedToken; +} + +/** + * Reconstructs final CSS properties from layer objects + */ +export function reconstructLayers(layers: MaskLayer[]): Record { + const result: Record = {}; + + // Collect all layer values for each property + const properties = { + "mask-image": layers.map((l) => l.image || MASK_DEFAULTS.image), + "mask-mode": layers.map((l) => l.mode || MASK_DEFAULTS.mode), + "mask-position": layers.map((l) => l.position || MASK_DEFAULTS.position), + "mask-size": layers.map((l) => l.size || MASK_DEFAULTS.size), + "mask-repeat": layers.map((l) => l.repeat || MASK_DEFAULTS.repeat), + "mask-origin": layers.map((l) => l.origin || MASK_DEFAULTS.origin), + "mask-clip": layers.map((l) => l.clip || MASK_DEFAULTS.clip), + "mask-composite": layers.map((l) => l.composite || MASK_DEFAULTS.composite), + }; + + // Join layer values with commas + Object.entries(properties).forEach(([property, values]) => { + result[property] = values.join(", "); + }); + + return result; +} + + +=== File: src/handlers/object-position/expand.ts === +// b_path:: src/handlers/object-position/expand.ts + +import { parsePosition } from "../../internal/position-parser"; + +export function expandObjectPosition(value: string): Record { + const { x, y } = parsePosition(value); + + return { + "object-position-x": x, + "object-position-y": y, + }; +} + + +=== File: src/handlers/object-position/index.ts === +// b_path:: src/handlers/object-position/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandObjectPosition } from "./expand"; + +export const objectPositionHandler: PropertyHandler = { + meta: { + shorthand: "object-position", + longhands: ["object-position-x", "object-position-y"], + category: "position", + }, + expand: (value) => expandObjectPosition(value), +}; + +export { expandObjectPosition }; + + +=== File: src/handlers/offset/expand.ts === +// b_path:: src/handlers/offset/expand.ts + +// NOTE: This handler contains complex path syntax and coordinate system parsing logic +// (~273 lines) that is a candidate for future refactoring. The implementation handles +// offset-position, offset-path (with path() and basic shapes), offset-distance, +// offset-rotate, and offset-anchor with various coordinate systems. The parsing logic +// is preserved as-is. + +import isAngle from "@/internal/is-angle"; +import isLength from "@/internal/is-length"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; + +function splitTopLevelSlash(input: string): [string, string?] | null { + let depth = 0; + let inQuotes = false; + let quoteChar = ""; + let slashIndex = -1; + + for (let i = 0; i < input.length; i++) { + const char = input[i]; + + if (!inQuotes && (char === '"' || char === "'")) { + inQuotes = true; + quoteChar = char; + } else if (inQuotes && char === quoteChar) { + inQuotes = false; + quoteChar = ""; + } else if (!inQuotes) { + if (char === "(") { + depth++; + } else if (char === ")") { + depth--; + } else if (char === "/" && depth === 0) { + if (slashIndex !== -1) { + // More than one top-level slash + return null; + } + slashIndex = i; + } + } + } + + if (slashIndex === -1) { + return [input.trim()]; + } + + const main = input.slice(0, slashIndex).trim(); + const anchor = input.slice(slashIndex + 1).trim(); + return [main, anchor]; +} + +function tokenizeRespectingFunctions(input: string): string[] { + const tokens: string[] = []; + let current = ""; + let depth = 0; + let inQuotes = false; + let quoteChar = ""; + + for (let i = 0; i < input.length; i++) { + const char = input[i]; + + if (!inQuotes && (char === '"' || char === "'")) { + inQuotes = true; + quoteChar = char; + current += char; + } else if (inQuotes && char === quoteChar) { + inQuotes = false; + quoteChar = ""; + current += char; + } else if (!inQuotes) { + if (char === "(") { + depth++; + current += char; + } else if (char === ")") { + depth--; + current += char; + } else if (char === " " && depth === 0) { + if (current.trim()) { + tokens.push(current.trim()); + current = ""; + } + } else { + current += char; + } + } else { + current += char; + } + } + + if (current.trim()) { + tokens.push(current.trim()); + } + + return tokens; +} + +function isPositionKeyword(token: string): boolean { + return /^(auto|normal|left|right|top|bottom|center)$/i.test(token); +} + +function parsePosition(tokens: string[]): { position: string; consumed: number } | null { + if (tokens.length === 0) return null; + + // Two tokens: x y (check first to avoid ambiguity) + if (tokens.length >= 2) { + const first = tokens[0]; + const second = tokens[1]; + if ( + (isPositionKeyword(first) || isLength(first)) && + (isPositionKeyword(second) || isLength(second)) + ) { + return { position: `${first} ${second}`, consumed: 2 }; + } + } + + // auto or normal + if (tokens[0] === "auto" || tokens[0] === "normal") { + return { position: tokens[0], consumed: 1 }; + } + + // Single keyword + if (isPositionKeyword(tokens[0])) { + return { position: tokens[0], consumed: 1 }; + } + + // Single length/% (horizontal center) + if (isLength(tokens[0])) { + return { position: tokens[0], consumed: 1 }; + } + + return null; +} + +function parsePath(tokens: string[]): { path: string; consumed: number } | null { + if (tokens.length === 0) return null; + + const token = tokens[0]; + + if (token === "none") { + return { path: "none", consumed: 1 }; + } + + if (token.startsWith("path(") || token.startsWith("ray(") || token.startsWith("url(")) { + return { path: token, consumed: 1 }; + } + + return null; +} + +function parseDistance(tokens: string[]): { distance: string; consumed: number } | null { + if (tokens.length === 0) return null; + + if (isLength(tokens[0])) { + return { distance: tokens[0], consumed: 1 }; + } + + return null; +} + +function parseRotate(tokens: string[]): { rotate: string; consumed: number } | null { + if (tokens.length === 0) return null; + + // Check for compound forms first: auto or reverse or auto/reverse + if (tokens.length >= 2) { + const first = tokens[0]; + const second = tokens[1]; + + if ((first === "auto" || first === "reverse") && isAngle(second)) { + return { rotate: `${first} ${second}`, consumed: 2 }; + } + if (isAngle(first) && (second === "auto" || second === "reverse")) { + return { rotate: `${second} ${first}`, consumed: 2 }; + } + } + + // Single tokens + const token = tokens[0]; + + if (token === "auto" || token === "reverse") { + return { rotate: token, consumed: 1 }; + } + + if (isAngle(token)) { + return { rotate: token, consumed: 1 }; + } + + return null; +} + +function parseAnchor(anchor: string): string | null { + if (!anchor) return null; + + const tokens = tokenizeRespectingFunctions(anchor); + + if (tokens.length === 1) { + if (tokens[0] === "auto" || isPositionKeyword(tokens[0])) { + return tokens[0]; + } + } + + if (tokens.length === 2) { + if ( + (isPositionKeyword(tokens[0]) || isLength(tokens[0])) && + (isPositionKeyword(tokens[1]) || isLength(tokens[1])) + ) { + return `${tokens[0]} ${tokens[1]}`; + } + } + + return null; +} + +function parseOffsetValue(value: string): Record | undefined { + // Handle global keywords + if (/^(inherit|initial|unset|revert)$/i.test(value)) { + return { + "offset-position": value, + "offset-path": value, + "offset-distance": value, + "offset-rotate": value, + "offset-anchor": value, + }; + } + + // Split on top-level slash + const slashSplit = splitTopLevelSlash(value); + if (!slashSplit) return undefined; // Invalid: multiple slashes + + const [main, anchor] = slashSplit; + + // Tokenize main part + const tokens = tokenizeRespectingFunctions(main); + if (tokens.length === 0) return undefined; + + const result: Record = {}; + + let index = 0; + + // Try to parse position first + const positionResult = parsePosition(tokens.slice(index)); + if (positionResult) { + result["offset-position"] = positionResult.position; + index += positionResult.consumed; + } + + // Try to parse path + const pathResult = parsePath(tokens.slice(index)); + if (pathResult) { + result["offset-path"] = pathResult.path; + index += pathResult.consumed; + } else { + // Path is required unless we have position only + if (index === 0) return undefined; + } + + // Try to parse distance + const distanceResult = parseDistance(tokens.slice(index)); + if (distanceResult) { + result["offset-distance"] = distanceResult.distance; + index += distanceResult.consumed; + } + + // Try to parse rotate + const rotateResult = parseRotate(tokens.slice(index)); + if (rotateResult) { + result["offset-rotate"] = rotateResult.rotate; + index += rotateResult.consumed; + } + + // Check if all tokens consumed + if (index !== tokens.length) return undefined; + + // Parse anchor if present + if (anchor) { + const parsedAnchor = parseAnchor(anchor); + if (parsedAnchor === null) return undefined; + result["offset-anchor"] = parsedAnchor; + } + + return result; +} + +export const offsetHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "offset", + longhands: [ + "offset-position", + "offset-path", + "offset-distance", + "offset-rotate", + "offset-anchor", + ], + defaults: { + "offset-position": "normal", + "offset-path": "none", + "offset-distance": "0", + "offset-rotate": "auto", + "offset-anchor": "auto", + }, + category: "visual", + }, + + expand: (value: string) => parseOffsetValue(value), + + validate: (value: string) => offsetHandler.expand(value) !== undefined, +}); + +export default (value: string): Record | undefined => { + return offsetHandler.expand(value); +}; + + +=== File: src/handlers/offset/index.ts === +// b_path:: src/handlers/offset/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/outline/expand.ts === +// b_path:: src/handlers/outline/expand.ts + +import isColor from "@/internal/is-color"; +import isLength from "@/internal/is-length"; +import normalizeColor from "@/internal/normalize-color"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; +import { sortProperties } from "@/internal/property-sorter"; + +const WIDTH = /^(thin|medium|thick)$/; +const STYLE = /^(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/i; +const KEYWORD = /^(inherit|initial|unset|revert)$/i; + +/** + * Property handler for the 'outline' CSS shorthand property + * + * Expands outline into outline-width, outline-style, and outline-color. + * + * @example + * ```typescript + * outlineHandler.expand('2px solid red'); // { 'outline-width': '2px', 'outline-style': 'solid', 'outline-color': 'red' } + * outlineHandler.expand('dashed'); // { 'outline-width': 'medium', 'outline-style': 'dashed', 'outline-color': 'currentcolor' } + * ``` + */ +export const outlineHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "outline", + longhands: ["outline-width", "outline-style", "outline-color"], + defaults: { + "outline-width": "medium", + "outline-style": "none", + "outline-color": "currentcolor", + }, + category: "visual", + }, + + expand: (value: string): Record | undefined => { + const values = normalizeColor(value).split(/\s+/); + + if (values.length > 3) return; + if (values.length === 1 && KEYWORD.test(values[0])) { + return sortProperties({ + "outline-width": values[0], + "outline-style": values[0], + "outline-color": values[0], + }); + } + + const parsed: { width?: string; style?: string; color?: string } = {}; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + + if (isLength(v) || WIDTH.test(v)) { + if (parsed.width) return; + parsed.width = v; + } else if (STYLE.test(v)) { + if (parsed.style) return; + parsed.style = v; + } else if (isColor(v)) { + if (parsed.color) return; + parsed.color = v; + } else { + return; + } + } + + // Use defaults for missing properties + // Per CSS spec, the default values for outline shorthand are: + // width: 'medium', style: 'none', color: 'currentcolor' + // See: https://drafts.csswg.org/css-ui-4/#propdef-outline + return sortProperties({ + "outline-width": parsed.width || "medium", + "outline-style": parsed.style || "none", + "outline-color": parsed.color || "currentcolor", + }); + }, + + validate: (value: string): boolean => { + return outlineHandler.expand(value) !== undefined; + }, +}); + +export default function outline(value: string): Record | undefined { + return outlineHandler.expand(value); +} + + +=== File: src/handlers/outline/index.ts === +// b_path:: src/handlers/outline/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/overflow/expand.ts === +// b_path:: src/handlers/overflow/expand.ts +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; + +/** + * Property handler for the 'overflow' CSS shorthand property + * + * Expands overflow into overflow-x and overflow-y. + * + * @example + * ```typescript + * overflowHandler.expand('hidden'); // { 'overflow-x': 'hidden', 'overflow-y': 'hidden' } + * overflowHandler.expand('auto scroll'); // { 'overflow-x': 'auto', 'overflow-y': 'scroll' } + * ``` + */ +export const overflowHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "overflow", + longhands: ["overflow-x", "overflow-y"], + category: "visual", + }, + + expand: (value: string): Record | undefined => { + // Handle global CSS keywords + if (/^(inherit|initial|unset|revert)$/i.test(value)) { + return { + "overflow-x": value, + "overflow-y": value, + }; + } + + // Split values on whitespace + const values = value.trim().split(/\s+/); + + // Validate value count - max 2 values + if (values.length > 2) { + return undefined; + } + + // Valid overflow values + const validValues = /^(visible|hidden|clip|scroll|auto)$/i; + + // Handle single value - both x and y get the same value + if (values.length === 1) { + if (!validValues.test(values[0])) { + return undefined; + } + return { + "overflow-x": values[0], + "overflow-y": values[0], + }; + } + + // Handle two values - first=x, second=y + if (values.length === 2) { + if (!validValues.test(values[0]) || !validValues.test(values[1])) { + return undefined; + } + return { + "overflow-x": values[0], + "overflow-y": values[1], + }; + } + + return undefined; + }, + + validate: (value: string): boolean => { + const result = overflowHandler.expand(value); + return result !== undefined; + }, +}); + +// Export default for backward compatibility with existing code +export default (value: string): Record | undefined => { + return overflowHandler.expand(value); +}; + + +=== File: src/handlers/overflow/index.ts === +// b_path:: src/handlers/overflow/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/padding/expand.ts === +// b_path:: src/handlers/padding/expand.ts + +import { createTRBLExpander } from "../../internal/trbl-expander"; + +export const expandPadding = createTRBLExpander("padding"); + + +=== File: src/handlers/padding/index.ts === +// b_path:: src/handlers/padding/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandPadding } from "./expand"; + +export const paddingHandler: PropertyHandler = { + meta: { + shorthand: "padding", + longhands: ["padding-top", "padding-right", "padding-bottom", "padding-left"], + category: "box-model", + }, + expand: (value) => expandPadding(value), +}; + +export { expandPadding }; + + +=== File: src/handlers/place-content/expand.ts === +// b_path:: src/handlers/place-content/expand.ts + +import { consolidatePlaceTokens } from "@/internal/place-utils"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; + +/** + * Property handler for the 'place-content' CSS shorthand property + * + * Expands place-content into align-content and justify-content. + * + * @example + * ```typescript + * placeContentHandler.expand('center'); // { 'align-content': 'center', 'justify-content': 'center' } + * placeContentHandler.expand('start space-between'); // { 'align-content': 'start', 'justify-content': 'space-between' } + * ``` + */ +export const placeContentHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "place-content", + longhands: ["align-content", "justify-content"], + defaults: { + "align-content": "normal", + "justify-content": "normal", + }, + category: "layout", + }, + + expand: (value: string): Record | undefined => { + // Handle global CSS keywords + if (/^(inherit|initial|unset|revert)$/i.test(value)) { + return { + "align-content": value, + "justify-content": value, + }; + } + + // Process tokens with lookahead for compound keywords + const processedValues = consolidatePlaceTokens( + value, + /^(center|start|end|flex-start|flex-end)$/i + ); + if (!processedValues) { + return undefined; + } + + // Validate processed values + const validValuePattern = + /^(normal|space-between|space-around|space-evenly|stretch|center|start|end|flex-start|flex-end|baseline|first baseline|last baseline|safe center|safe start|safe end|safe flex-start|safe flex-end|unsafe center|unsafe start|unsafe end|unsafe flex-start|unsafe flex-end)$/i; + + for (const val of processedValues) { + if (!validValuePattern.test(val)) { + return undefined; + } + } + + // Handle single value - both properties get the same value + if (processedValues.length === 1) { + return { + "align-content": processedValues[0], + "justify-content": processedValues[0], + }; + } + + // Handle two values - first=align-content, second=justify-content + if (processedValues.length === 2) { + return { + "align-content": processedValues[0], + "justify-content": processedValues[1], + }; + } + + return undefined; + }, + + validate: (value: string): boolean => { + const result = placeContentHandler.expand(value); + return result !== undefined; + }, +}); + +// Export default for backward compatibility with existing code +export default (value: string): Record | undefined => { + return placeContentHandler.expand(value); +}; + + +=== File: src/handlers/place-content/index.ts === +// b_path:: src/handlers/place-content/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/place-items/expand.ts === +// b_path:: src/handlers/place-items/expand.ts + +import { consolidatePlaceTokens } from "@/internal/place-utils"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; + +/** + * Property handler for the 'place-items' CSS shorthand property + * + * Expands place-items into align-items and justify-items. + * + * @example + * ```typescript + * placeItemsHandler.expand('center'); // { 'align-items': 'center', 'justify-items': 'center' } + * placeItemsHandler.expand('start end'); // { 'align-items': 'start', 'justify-items': 'end' } + * ``` + */ +export const placeItemsHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "place-items", + longhands: ["align-items", "justify-items"], + defaults: { + "align-items": "normal", + "justify-items": "legacy", + }, + category: "layout", + }, + + expand: (value: string): Record | undefined => { + // Handle global CSS keywords + if (/^(inherit|initial|unset|revert)$/i.test(value)) { + return { + "align-items": value, + "justify-items": value, + }; + } + + // Process tokens with lookahead for compound keywords + const processedValues = consolidatePlaceTokens( + value, + /^(center|start|end|self-start|self-end|flex-start|flex-end)$/i + ); + if (!processedValues) { + return undefined; + } + + // Helper functions for validation + const isValidAlignItemsValue = (val: string): boolean => { + return /^(normal|stretch|center|start|end|self-start|self-end|flex-start|flex-end|baseline|first baseline|last baseline|safe center|safe start|safe end|safe self-start|safe self-end|safe flex-start|safe flex-end|unsafe center|unsafe start|unsafe end|unsafe self-start|unsafe self-end|unsafe flex-start|unsafe flex-end)$/i.test( + val + ); + }; + + const isValidJustifyItemsValue = (val: string): boolean => { + return /^(normal|stretch|center|start|end|self-start|self-end|flex-start|flex-end|baseline|first baseline|last baseline|left|right|safe center|safe start|safe end|safe self-start|safe self-end|safe flex-start|safe flex-end|unsafe center|unsafe start|unsafe end|unsafe self-start|unsafe self-end|unsafe flex-start|unsafe flex-end)$/i.test( + val + ); + }; + + // Validate processed values + for (const val of processedValues) { + if (!isValidAlignItemsValue(val) && !isValidJustifyItemsValue(val)) { + return undefined; + } + } + + // Handle single value - both properties get the same value, but left/right are invalid for single value + if (processedValues.length === 1) { + const val = processedValues[0]; + if (val === "left" || val === "right") { + return undefined; + } + if (!isValidAlignItemsValue(val)) { + return undefined; + } + return { + "align-items": val, + "justify-items": val, + }; + } + + // Handle two values - first=align-items, second=justify-items + if (processedValues.length === 2) { + const [alignVal, justifyVal] = processedValues; + if (!isValidAlignItemsValue(alignVal) || alignVal === "left" || alignVal === "right") { + return undefined; + } + if (!isValidJustifyItemsValue(justifyVal)) { + return undefined; + } + return { + "align-items": alignVal, + "justify-items": justifyVal, + }; + } + + return undefined; + }, + + validate: (value: string): boolean => { + const result = placeItemsHandler.expand(value); + return result !== undefined; + }, +}); + +// Export default for backward compatibility with existing code +export default (value: string): Record | undefined => { + return placeItemsHandler.expand(value); +}; + + +=== File: src/handlers/place-items/index.ts === +// b_path:: src/handlers/place-items/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/place-self/expand.ts === +// b_path:: src/handlers/place-self/expand.ts + +import { consolidatePlaceTokens } from "@/internal/place-utils"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; + +/** + * Property handler for the 'place-self' CSS shorthand property + * + * Expands place-self into align-self and justify-self. + * + * @example + * ```typescript + * placeSelfHandler.expand('center'); // { 'align-self': 'center', 'justify-self': 'center' } + * placeSelfHandler.expand('start end'); // { 'align-self': 'start', 'justify-self': 'end' } + * ``` + */ +export const placeSelfHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "place-self", + longhands: ["align-self", "justify-self"], + defaults: { + "align-self": "auto", + "justify-self": "auto", + }, + category: "layout", + }, + + expand: (value: string): Record | undefined => { + // Handle global CSS keywords + if (/^(inherit|initial|unset|revert)$/i.test(value)) { + return { + "align-self": value, + "justify-self": value, + }; + } + + // Process tokens with lookahead for compound keywords + const processedValues = consolidatePlaceTokens( + value, + /^(center|start|end|self-start|self-end|flex-start|flex-end)$/i + ); + if (!processedValues) { + return undefined; + } + + // Helper functions for validation + const isValidAlignSelfValue = (val: string): boolean => { + return /^(auto|normal|stretch|center|start|end|self-start|self-end|flex-start|flex-end|baseline|first baseline|last baseline|anchor-center|safe center|safe start|safe end|safe self-start|safe self-end|safe flex-start|safe flex-end|unsafe center|unsafe start|unsafe end|unsafe self-start|unsafe self-end|unsafe flex-start|unsafe flex-end)$/i.test( + val + ); + }; + + const isValidJustifySelfValue = (val: string): boolean => { + return /^(auto|normal|stretch|center|start|end|self-start|self-end|flex-start|flex-end|baseline|first baseline|last baseline|left|right|safe center|safe start|safe end|safe self-start|safe self-end|safe flex-start|safe flex-end|unsafe center|unsafe start|unsafe end|unsafe self-start|unsafe self-end|unsafe flex-start|unsafe flex-end)$/i.test( + val + ); + }; + + // Validate processed values + for (const val of processedValues) { + if (!isValidAlignSelfValue(val) && !isValidJustifySelfValue(val)) { + return undefined; + } + } + + // Handle single value - both properties get the same value, but left/right are invalid for single value + if (processedValues.length === 1) { + const val = processedValues[0]; + if (val === "left" || val === "right") { + return undefined; + } + if (!isValidAlignSelfValue(val)) { + return undefined; + } + return { + "align-self": val, + "justify-self": val, + }; + } + + // Handle two values - first=align-self, second=justify-self + if (processedValues.length === 2) { + const [alignVal, justifyVal] = processedValues; + if (!isValidAlignSelfValue(alignVal) || alignVal === "left" || alignVal === "right") { + return undefined; + } + if (!isValidJustifySelfValue(justifyVal)) { + return undefined; + } + return { + "align-self": alignVal, + "justify-self": justifyVal, + }; + } + + return undefined; + }, + + validate: (value: string): boolean => { + const result = placeSelfHandler.expand(value); + return result !== undefined; + }, +}); + +// Export default for backward compatibility with existing code +export default (value: string): Record | undefined => { + return placeSelfHandler.expand(value); +}; + + +=== File: src/handlers/place-self/index.ts === +// b_path:: src/handlers/place-self/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/scroll-margin/expand.ts === +// b_path:: src/handlers/scroll-margin/expand.ts + +import { createTRBLExpander } from "../../internal/trbl-expander"; + +export const expandScrollMargin = createTRBLExpander("scroll-margin"); + + +=== File: src/handlers/scroll-margin/index.ts === +// b_path:: src/handlers/scroll-margin/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandScrollMargin } from "./expand"; + +export const scrollMarginHandler: PropertyHandler = { + meta: { + shorthand: "scroll-margin", + longhands: [ + "scroll-margin-top", + "scroll-margin-right", + "scroll-margin-bottom", + "scroll-margin-left", + ], + category: "box-model", + }, + expand: (value) => expandScrollMargin(value), +}; + +export { expandScrollMargin }; + + +=== File: src/handlers/scroll-padding/expand.ts === +// b_path:: src/handlers/scroll-padding/expand.ts + +import { createTRBLExpander } from "../../internal/trbl-expander"; + +export const expandScrollPadding = createTRBLExpander("scroll-padding"); + + +=== File: src/handlers/scroll-padding/index.ts === +// b_path:: src/handlers/scroll-padding/index.ts + +import type { PropertyHandler } from "../../internal/property-handler"; +import { expandScrollPadding } from "./expand"; + +export const scrollPaddingHandler: PropertyHandler = { + meta: { + shorthand: "scroll-padding", + longhands: [ + "scroll-padding-top", + "scroll-padding-right", + "scroll-padding-bottom", + "scroll-padding-left", + ], + category: "box-model", + }, + expand: (value) => expandScrollPadding(value), +}; + +export { expandScrollPadding }; + + +=== File: src/handlers/text-decoration/expand.ts === +// b_path:: src/handlers/text-decoration/expand.ts + +import isColor from "@/internal/is-color"; +import isLength from "@/internal/is-length"; +import normalizeColor from "@/internal/normalize-color"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; +import { sortProperties } from "@/internal/property-sorter"; + +const KEYWORD = /^(inherit|initial|unset|revert)$/i; +const LINE = /^(none|underline|overline|line-through|blink|spelling-error|grammar-error)$/i; +const STYLE = /^(solid|double|dotted|dashed|wavy)$/i; +const THICKNESS = /^(auto|from-font)$/i; + +/** + * Property handler for the 'text-decoration' CSS shorthand property + * + * Expands text-decoration into text-decoration-line, text-decoration-style, + * text-decoration-color, and text-decoration-thickness. + * + * @example + * ```typescript + * textDecorationHandler.expand('underline'); // { 'text-decoration-line': 'underline', 'text-decoration-style': 'solid', 'text-decoration-color': 'currentColor', 'text-decoration-thickness': 'auto' } + * textDecorationHandler.expand('underline dotted red'); // { 'text-decoration-line': 'underline', 'text-decoration-style': 'dotted', 'text-decoration-color': 'red', 'text-decoration-thickness': 'auto' } + * textDecorationHandler.expand('overline line-through wavy'); // { 'text-decoration-line': 'overline line-through', 'text-decoration-style': 'wavy', 'text-decoration-color': 'currentColor', 'text-decoration-thickness': 'auto' } + * ``` + */ +export const textDecorationHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "text-decoration", + longhands: [ + "text-decoration-line", + "text-decoration-style", + "text-decoration-color", + "text-decoration-thickness", + ], + defaults: { + "text-decoration-line": "none", + "text-decoration-style": "solid", + "text-decoration-color": "currentColor", + "text-decoration-thickness": "auto", + }, + category: "typography", + }, + + expand: (value: string): Record | undefined => { + const values = normalizeColor(value).split(/\s+/); + + if (values.length === 1 && KEYWORD.test(values[0])) { + return sortProperties({ + "text-decoration-line": values[0], + "text-decoration-style": values[0], + "text-decoration-color": values[0], + "text-decoration-thickness": values[0], + }); + } + + // Initialize with defaults - text-decoration shorthand resets all properties + const result: Record = { + "text-decoration-line": "none", + "text-decoration-style": "solid", + "text-decoration-color": "currentColor", + "text-decoration-thickness": "auto", + }; + + const lines: string[] = []; + let hasStyle = false; + let hasColor = false; + let hasThickness = false; + + for (let i = 0; i < values.length; i++) { + const v = values[i]; + + if (LINE.test(v)) { + lines.push(v); + } else if (STYLE.test(v)) { + if (hasStyle) return; // Duplicate style + result["text-decoration-style"] = v; + hasStyle = true; + } else if (isColor(v)) { + if (hasColor) return; // Duplicate color + result["text-decoration-color"] = v; + hasColor = true; + } else if (THICKNESS.test(v) || isLength(v)) { + if (hasThickness) return; // Duplicate thickness + result["text-decoration-thickness"] = v; + hasThickness = true; + } else { + return; + } + } + + if (lines.length > 1 && lines.includes("none")) return; + + if (lines.length > 0) { + if (lines.length !== new Set(lines).size) return; // Duplicate lines + result["text-decoration-line"] = lines.join(" "); + } + + return sortProperties(result); + }, + + validate: (value: string): boolean => { + return textDecorationHandler.expand(value) !== undefined; + }, +}); + +// Export default for backward compatibility with existing code +export default function textDecoration(value: string): Record | undefined { + return textDecorationHandler.expand(value); +} + + +=== File: src/handlers/text-decoration/index.ts === +// b_path:: src/handlers/text-decoration/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/text-emphasis/expand.ts === +// b_path:: src/handlers/text-emphasis/expand.ts + +import isColor from "@/internal/is-color"; +import normalizeColor from "@/internal/normalize-color"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; +import { sortProperties } from "@/internal/property-sorter"; + +const KEYWORD = /^(inherit|initial|unset|revert)$/i; +const FILL = /^(filled|open)$/i; +const SHAPE = /^(dot|circle|double-circle|triangle|sesame)$/i; +const STRING_VALUE = /^["'].*["']$/; + +/** + * Property handler for the 'text-emphasis' CSS shorthand property + * + * Expands text-emphasis into text-emphasis-style and text-emphasis-color. + * + * @example + * ```typescript + * textEmphasisHandler.expand('filled dot'); // { 'text-emphasis-style': 'filled dot', 'text-emphasis-color': 'currentcolor' } + * textEmphasisHandler.expand('circle red'); // { 'text-emphasis-style': 'circle', 'text-emphasis-color': 'red' } + * textEmphasisHandler.expand('"x"'); // { 'text-emphasis-style': '"x"', 'text-emphasis-color': 'currentcolor' } + * ``` + */ +export const textEmphasisHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "text-emphasis", + longhands: ["text-emphasis-style", "text-emphasis-color"], + defaults: { + "text-emphasis-style": "none", + "text-emphasis-color": "currentcolor", + }, + category: "typography", + }, + + expand: (value: string): Record | undefined => { + const values = normalizeColor(value).split(/\s+/); + + if (values.length === 1 && KEYWORD.test(values[0])) { + return sortProperties({ + "text-emphasis-style": values[0], + "text-emphasis-color": values[0], + }); + } + + const parsed: { style?: string; color?: string } = {}; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + + if (v === "none") { + if (parsed.style) return; + parsed.style = v; + } else if (STRING_VALUE.test(v)) { + if (parsed.style) return; + parsed.style = v; + } else if (FILL.test(v)) { + if (parsed.style) return; + if (i + 1 < values.length && SHAPE.test(values[i + 1])) { + parsed.style = `${v} ${values[i + 1]}`; + i++; + } else { + parsed.style = v; + } + } else if (SHAPE.test(v)) { + if (parsed.style) return; + if (i + 1 < values.length && FILL.test(values[i + 1])) { + parsed.style = `${values[i + 1]} ${v}`; + i++; + } else { + parsed.style = v; + } + } else if (isColor(v)) { + if (parsed.color) return; + parsed.color = v; + } else { + return; + } + } + + // Use defaults for missing properties + // Per CSS spec, the default values for text-emphasis shorthand are: + // style: 'none', color: 'currentcolor' + // See: https://www.w3.org/TR/css-text-decor-3/#propdef-text-emphasis + return sortProperties({ + "text-emphasis-style": parsed.style || "none", + "text-emphasis-color": parsed.color || "currentcolor", + }); + }, + + validate: (value: string): boolean => { + return textEmphasisHandler.expand(value) !== undefined; + }, +}); + +// Export default for backward compatibility with existing code +export default function textEmphasis(value: string): Record | undefined { + return textEmphasisHandler.expand(value); +} + + +=== File: src/handlers/text-emphasis/index.ts === +// b_path:: src/handlers/text-emphasis/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/transition/expand.ts === +// b_path:: src/handlers/transition/expand.ts + +// NOTE: This handler contains complex multi-layer parsing logic that is a candidate +// for future refactoring. Property name handling and timing parsing could be simplified. + +import isTime from "@/internal/is-time"; +import isTimingFunction from "@/internal/is-timing-function"; +import { createPropertyHandler, type PropertyHandler } from "@/internal/property-handler"; +import { needsAdvancedParser, parseTransitionLayers, reconstructLayers } from "./transition-layers"; + +const KEYWORD = /^(inherit|initial|unset|revert)$/i; +const PROPERTY_KEYWORD = /^(none|all)$/i; + +function parseTransitionValue(value: string): Record | undefined { + // Handle global keywords first + if (KEYWORD.test(value.trim())) { + return { + "transition-property": value.trim(), + "transition-duration": value.trim(), + "transition-timing-function": value.trim(), + "transition-delay": value.trim(), + }; + } + + // Check for multi-layer syntax + if (needsAdvancedParser(value)) { + const layeredResult = parseTransitionLayers(value); + if (layeredResult) { + return reconstructLayers(layeredResult.layers); + } + return undefined; // Advanced parsing failed + } + + // Simple single-layer fallback parser + const result: Record = {}; + const tokens = value.trim().split(/\s+/); + let timeCount = 0; // Track first vs second time value + + for (const token of tokens) { + // Handle time values first (duration and delay) - CSS allows flexible ordering + if (isTime(token) || token.startsWith("var(")) { + if (timeCount === 0) { + result["transition-duration"] = token; + } else if (timeCount === 1) { + result["transition-delay"] = token; + } else { + // More than 2 time values is invalid + return undefined; + } + timeCount++; + continue; + } + + // Handle timing functions + if (!result["transition-timing-function"] && isTimingFunction(token)) { + result["transition-timing-function"] = token; + continue; + } + + // Handle transition-property (none, all, or CSS property names) + // Only if we haven't set it yet, and it's not a timing function + if (!result["transition-property"]) { + if (PROPERTY_KEYWORD.test(token)) { + result["transition-property"] = token; + continue; + } + // Check if it looks like a CSS property name (not a timing function) + // Allow vendor-prefixed properties starting with hyphen + if (/^-?[a-zA-Z][a-zA-Z0-9-]*$/.test(token) && !isTimingFunction(token)) { + result["transition-property"] = token; + continue; + } + } + + // If token doesn't match any category, it's invalid + return undefined; + } + + // Accept single-token property values - they will expand to defaults + + // Build final result with defaults + return { + "transition-property": result["transition-property"] || "all", + "transition-duration": result["transition-duration"] || "0s", + "transition-timing-function": result["transition-timing-function"] || "ease", + "transition-delay": result["transition-delay"] || "0s", + }; +} + +/** + * Property handler for the 'transition' CSS shorthand property + * + * Expands transition into transition-property, transition-duration, + * transition-timing-function, and transition-delay. + * + * @example + * ```typescript + * transitionHandler.expand('opacity 300ms ease-in'); + * transitionHandler.expand('all 1s'); + * ``` + */ +export const transitionHandler: PropertyHandler = createPropertyHandler({ + meta: { + shorthand: "transition", + longhands: [ + "transition-property", + "transition-duration", + "transition-timing-function", + "transition-delay", + ], + defaults: { + "transition-property": "all", + "transition-duration": "0s", + "transition-timing-function": "ease", + "transition-delay": "0s", + }, + category: "animation", + }, + + expand: (value: string): Record | undefined => { + return parseTransitionValue(value); + }, + + validate: (value: string): boolean => { + return transitionHandler.expand(value) !== undefined; + }, +}); + +export default function transition(value: string): Record | undefined { + return transitionHandler.expand(value); +} + + +=== File: src/handlers/transition/index.ts === +// b_path:: src/handlers/transition/index.ts + +export * from "./expand"; +export { default } from "./expand"; + + +=== File: src/handlers/transition/transition-layers.ts === +// b_path:: src/handlers/transition/transition-layers.ts + +import * as csstree from "@eslint/css-tree"; +import type { TransitionLayer, TransitionResult } from "@/core/schema"; +import isTime from "@/internal/is-time"; +import isTimingFunction from "@/internal/is-timing-function"; +import { matchesType } from "@/internal/is-value-node"; +import { hasTopLevelCommas, parseLayersGeneric } from "@/internal/layer-parser-utils"; + +// CSS default values for transition properties +export const TRANSITION_DEFAULTS = { + property: "all", + duration: "0s", + timingFunction: "ease", + delay: "0s", +} as const; + +/** + * Detects if a transition value needs advanced parsing (multi-layer transitions or complex functions) + */ +export function needsAdvancedParser(value: string): boolean { + return hasTopLevelCommas(value, true); +} + +/** + * Parses a complex transition value using css-tree AST parsing + */ +export function parseTransitionLayers(value: string): TransitionResult | undefined { + const layers = parseLayersGeneric(value, parseSingleLayer); + return layers ? { layers } : undefined; +} + +/** + * Parses a single transition layer using css-tree AST parsing + */ +function parseSingleLayer(layerValue: string): TransitionLayer | undefined { + const result: TransitionLayer = {}; + + const ast = csstree.parse(layerValue.trim(), { context: "value" }); + + // Collect all child nodes from the Value node + const children: csstree.CssNode[] = []; + csstree.walk(ast, { + visit: "Value", + enter: (node: csstree.CssNode) => { + if (node.type === "Value" && node.children) { + node.children.forEach((child) => { + children.push(child); + }); + } + }, + }); + + // Process children in order, handling transition property parsing + if (!processCssChildren(children, result)) { + return undefined; // Parsing failed due to invalid syntax + } + + return result; +} + +/** + * Processes CSS AST children sequentially to extract transition properties + * + * This function handles the parsing of CSS transition layer syntax, + * including property names, time values, and timing functions. + * CSS ordering rules: first time = duration, second time = delay + * + * Returns false if parsing should fail (e.g., too many time values) + */ +function processCssChildren(children: csstree.CssNode[], result: TransitionLayer): boolean { + let timeCount = 0; // Track first vs second time value + + for (const child of children) { + // Skip whitespace and operators + if (child.type === "WhiteSpace" || child.type === "Operator") { + continue; + } + + // Handle timing functions FIRST (keywords and functions) + // Must check before time values to avoid cubic-bezier() being treated as time + if (!result.timingFunction) { + if (child.type === "Identifier") { + const timingValue = csstree.generate(child); + if (isTimingFunction(timingValue)) { + result.timingFunction = timingValue; + continue; + } + } + + if (child.type === "Function") { + const funcValue = csstree.generate(child); + if (isTimingFunction(funcValue)) { + // Fix spacing in function calls (css-tree removes spaces after commas) + result.timingFunction = funcValue.replace(/,([^\s])/g, ", $1"); + continue; + } + } + } + + // Handle time values (duration and delay) + // Accept: Dimensions with time units (1s, 500ms), or any Function (calc, var, etc.) + if (matchesType(child, ["Dimension", "Function"])) { + const timeValue = csstree.generate(child); + + // For Dimensions, validate they have time units + // For Functions (var, calc), accept unconditionally (carry over as-is) + if (child.type === "Function" || isTime(timeValue)) { + if (timeCount >= 2) { + // More than 2 time values is invalid + return false; + } + if (timeCount === 0) { + result.duration = timeValue; + } else { + result.delay = timeValue; + } + timeCount++; + continue; + } + } + + // Handle transition-property (none, all, or CSS property names) + if (child.type === "Identifier" && !result.property) { + const name = (child as csstree.Identifier).name; + if (name === "none" || name === "all") { + result.property = name; + continue; + } + // Check if it looks like a CSS property name (not a timing function) + // Allow vendor-prefixed properties starting with hyphen + if (/^-?[a-zA-Z][a-zA-Z0-9-]*$/.test(name) && !isTimingFunction(name)) { + result.property = name; + } + } + } + + return true; +} + +/** + * Reconstructs final CSS properties from layer objects + */ +export function reconstructLayers(layers: TransitionLayer[]): Record { + const result: Record = {}; + + // Collect all layer values for each property + const properties = { + "transition-property": layers.map((l) => l.property || TRANSITION_DEFAULTS.property), + "transition-duration": layers.map((l) => l.duration || TRANSITION_DEFAULTS.duration), + "transition-timing-function": layers.map( + (l) => l.timingFunction || TRANSITION_DEFAULTS.timingFunction + ), + "transition-delay": layers.map((l) => l.delay || TRANSITION_DEFAULTS.delay), + }; + + // Join layer values with commas + Object.entries(properties).forEach(([property, values]) => { + result[property] = values.join(", "); + }); + + return result; +} + + +=== File: src/index.ts === +// b_path:: src/index.ts + +/** + * b_short - Lightning-fast CSS shorthand expansion to longhand properties + * + * Main entry point exporting the public API. + * + * @packageDocumentation + */ + +// ============================================================================ +// CORE API - Primary public exports +// ============================================================================ + +export { expand } from "./core/expand"; +export { validate } from "./core/validate"; + +// ============================================================================ +// TYPE EXPORTS - Configuration and result types +// ============================================================================ + +export type { + BStyleWarning, + ExpandResult, + Format, + PropertyGrouping, + StylesheetValidation, +} from "./core/schema"; + +// Export ExpandOptions as namespace (includes both interface and enums) +export { ExpandOptions } from "./core/schema"; + +// ============================================================================ +// LAYER TYPES - Multi-layer parsing result types +// ============================================================================ + +export type { + AnimationLayer, + AnimationResult, + BackgroundLayer, + BackgroundResult, + MaskLayer, + MaskResult, + TransitionLayer, + TransitionResult, +} from "./core/schema"; + +// ============================================================================ +// CONSTANTS - Runtime enum values +// ============================================================================ + +export { + DEFAULT_EXPAND_OPTIONS, + FORMAT_CSS, + FORMAT_JS, + FORMAT_VALUES, + GROUPING_BY_PROPERTY, + GROUPING_BY_SIDE, + PROPERTY_GROUPING_VALUES, +} from "./core/schema"; + +// ============================================================================ +// HANDLER REGISTRY - PropertyHandler infrastructure +// ============================================================================ + +export type { HandlerRegistry } from "./internal/handler-registry"; +export { + expandProperty, + isShorthandProperty, + registry, + validateProperty, +} from "./internal/handler-registry"; + +export type { + PropertyCategory, + PropertyHandler, + PropertyHandlerMetadata, + PropertyHandlerOptions, +} from "./internal/property-handler"; + +// ============================================================================ +// v2.0.0: Collapse API removed - Expansion only +// ============================================================================ +// Collapse functionality has been removed in v2.0.0. +// This version focuses exclusively on shorthand expansion. + + +=== File: src/internal/border-side-parser.ts === +// b_path:: src/internal/border-side-parser.ts + +/** + * Parse border-side shorthand (border-top, border-right, etc.) + * + * Grammar: || || + * All components are optional + */ + +const BORDER_STYLES = new Set([ + "none", + "hidden", + "dotted", + "dashed", + "solid", + "double", + "groove", + "ridge", + "inset", + "outset", +]); + +const BORDER_WIDTHS = new Set(["thin", "medium", "thick"]); + +export interface BorderSideResult { + width: string; + style: string; + color: string; +} + +export function parseBorderSide(value: string): BorderSideResult { + const trimmed = value.trim(); + + // Global values + if ( + trimmed === "initial" || + trimmed === "inherit" || + trimmed === "unset" || + trimmed === "revert" + ) { + return { width: trimmed, style: trimmed, color: trimmed }; + } + + let width = "medium"; + let style = "none"; + let color = "currentcolor"; + + // Split by whitespace preserving functions + const parts = smartSplit(trimmed); + + for (const part of parts) { + if (BORDER_STYLES.has(part.toLowerCase())) { + style = part; + } else if (BORDER_WIDTHS.has(part.toLowerCase()) || isLength(part)) { + width = part; + } else { + // Assume color + color = part; + } + } + + return { width, style, color }; +} + +function smartSplit(value: string): string[] { + const result: string[] = []; + let current = ""; + let depth = 0; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + + if (char === "(") { + depth++; + current += char; + } else if (char === ")") { + depth--; + current += char; + } else if (char === " " && depth === 0) { + if (current.trim()) { + result.push(current.trim()); + current = ""; + } + } else { + current += char; + } + } + + if (current.trim()) { + result.push(current.trim()); + } + + return result; +} + +function isLength(value: string): boolean { + if (value === "0") return true; + if (value.startsWith("calc(")) return true; + if (value.startsWith("var(")) return true; + // Check for length units + return /^-?\d*\.?\d+(px|em|rem|%|vh|vw|ch|ex|cm|mm|in|pt|pc)$/.test(value); +} + +export function createBorderSideExpander(side: "top" | "right" | "bottom" | "left") { + return (value: string): Record => { + const { width, style, color } = parseBorderSide(value); + return { + [`border-${side}-width`]: width, + [`border-${side}-style`]: style, + [`border-${side}-color`]: color, + }; + }; +} + + +=== File: src/internal/color-utils.ts === +// b_path:: src/internal/color-utils.ts +// Color utility functions - replacing external dependencies for better self-containment + +export const CSS_COLOR_NAMES: Record = { + aliceblue: "#F0F8FF", + antiquewhite: "#FAEBD7", + aqua: "#00FFFF", + aquamarine: "#7FFFD4", + azure: "#F0FFFF", + beige: "#F5F5DC", + bisque: "#FFE4C4", + black: "#000000", + blanchedalmond: "#FFEBCD", + blue: "#0000FF", + blueviolet: "#8A2BE2", + brown: "#A52A2A", + burlywood: "#DEB887", + cadetblue: "#5F9EA0", + chartreuse: "#7FFF00", + chocolate: "#D2691E", + coral: "#FF7F50", + cornflowerblue: "#6495ED", + cornsilk: "#FFF8DC", + crimson: "#DC143C", + cyan: "#00FFFF", + darkblue: "#00008B", + darkcyan: "#008B8B", + darkgoldenrod: "#B8860B", + darkgray: "#A9A9A9", + darkgrey: "#A9A9A9", + darkgreen: "#006400", + darkkhaki: "#BDB76B", + darkmagenta: "#8B008B", + darkolivegreen: "#556B2F", + darkorange: "#FF8C00", + darkorchid: "#9932CC", + darkred: "#8B0000", + darksalmon: "#E9967A", + darkseagreen: "#8FBC8F", + darkslateblue: "#483D8B", + darkslategray: "#2F4F4F", + darkslategrey: "#2F4F4F", + darkturquoise: "#00CED1", + darkviolet: "#9400D3", + deeppink: "#FF1493", + deepskyblue: "#00BFFF", + dimgray: "#696969", + dimgrey: "#696969", + dodgerblue: "#1E90FF", + firebrick: "#B22222", + floralwhite: "#FFFAF0", + forestgreen: "#228B22", + fuchsia: "#FF00FF", + gainsboro: "#DCDCDC", + ghostwhite: "#F8F8FF", + gold: "#FFD700", + goldenrod: "#DAA520", + gray: "#808080", + grey: "#808080", + green: "#008000", + greenyellow: "#ADFF2F", + honeydew: "#F0FFF0", + hotpink: "#FF69B4", + indianred: "#CD5C5C", + indigo: "#4B0082", + ivory: "#FFFFF0", + khaki: "#F0E68C", + lavender: "#E6E6FA", + lavenderblush: "#FFF0F5", + lawngreen: "#7CFC00", + lemonchiffon: "#FFFACD", + lightblue: "#ADD8E6", + lightcoral: "#F08080", + lightcyan: "#E0FFFF", + lightgoldenrodyellow: "#FAFAD2", + lightgray: "#D3D3D3", + lightgrey: "#D3D3D3", + lightgreen: "#90EE90", + lightpink: "#FFB6C1", + lightsalmon: "#FFA07A", + lightseagreen: "#20B2AA", + lightskyblue: "#87CEFA", + lightslategray: "#778899", + lightslategrey: "#778899", + lightsteelblue: "#B0C4DE", + lightyellow: "#FFFFE0", + lime: "#00FF00", + limegreen: "#32CD32", + linen: "#FAF0E6", + magenta: "#FF00FF", + maroon: "#800000", + mediumaquamarine: "#66CDAA", + mediumblue: "#0000CD", + mediumorchid: "#BA55D3", + mediumpurple: "#9370DB", + mediumseagreen: "#3CB371", + mediumslateblue: "#7B68EE", + mediumspringgreen: "#00FA9A", + mediumturquoise: "#48D1CC", + mediumvioletred: "#C71585", + midnightblue: "#191970", + mintcream: "#F5FFFA", + mistyrose: "#FFE4E1", + moccasin: "#FFE4B5", + navajowhite: "#FFDEAD", + navy: "#000080", + oldlace: "#FDF5E6", + olive: "#808000", + olivedrab: "#6B8E23", + orange: "#FFA500", + orangered: "#FF4500", + orchid: "#DA70D6", + palegoldenrod: "#EEE8AA", + palegreen: "#98FB98", + paleturquoise: "#AFEEEE", + palevioletred: "#DB7093", + papayawhip: "#FFEFD5", + peachpuff: "#FFDAB9", + peru: "#CD853F", + pink: "#FFC0CB", + plum: "#DDA0DD", + powderblue: "#B0E0E6", + purple: "#800080", + rebeccapurple: "#663399", + red: "#FF0000", + rosybrown: "#BC8F8F", + royalblue: "#4169E1", + saddlebrown: "#8B4513", + salmon: "#FA8072", + sandybrown: "#F4A460", + seagreen: "#2E8B57", + seashell: "#FFF5EE", + sienna: "#A0522D", + silver: "#C0C0C0", + skyblue: "#87CEEB", + slateblue: "#6A5ACD", + slategray: "#708090", + slategrey: "#708090", + snow: "#FFFAFA", + springgreen: "#00FF7F", + steelblue: "#4682B4", + tan: "#D2B48C", + teal: "#008080", + thistle: "#D8BFD8", + tomato: "#FF6347", + turquoise: "#40E0D0", + violet: "#EE82EE", + wheat: "#F5DEB3", + white: "#FFFFFF", + whitesmoke: "#F5F5F5", + yellow: "#FFFF00", + yellowgreen: "#9ACD32", +}; + +export function cssUrlRegex(): RegExp { + return /url\((.*?)\)/gi; +} + +export function hexColorRegex(opts?: { strict?: boolean }): RegExp { + opts = opts && typeof opts === "object" ? opts : {}; + return opts.strict + ? /^#([a-f0-9]{3,4}|[a-f0-9]{4}(?:[a-f0-9]{2}){1,2})\b$/i + : /#([a-f0-9]{3}|[a-f0-9]{4}(?:[a-f0-9]{2}){0,2})\b/gi; +} + +export function hslRegex(options?: { exact?: boolean }): RegExp { + options = options || {}; + return options.exact + ? /^hsl\(\s*(\d+)\s*[,\s]?\s*(\d*(?:\.\d+)?%)\s*[,\s]?\s*(\d*(?:\.\d+)?%)(?:\s*\/?\s*(\d*(?:\.\d+)?%?))?\s*\)$/ + : /hsl\(\s*(\d+)\s*[,\s]?\s*(\d*(?:\.\d+)?%)\s*[,\s]?\s*(\d*(?:\.\d+)?%)(?:\s*\/?\s*(\d*(?:\.\d+)?%?))?\s*\)/gi; +} + +export function hslaRegex(options?: { exact?: boolean }): RegExp { + options = options || {}; + return options.exact + ? /^hsla\(\s*(\d+)\s*[,\s]?\s*(\d*(?:\.\d+)?%)\s*[,\s]?\s*(\d*(?:\.\d+)?%)\s*[,/]?\s*(\d*(?:\.\d+)?%?)\s*\)$/ + : /hsla\(\s*(\d+)\s*[,\s]?\s*(\d*(?:\.\d+)?%)\s*[,\s]?\s*(\d*(?:\.\d+)?%)\s*[,/]?\s*(\d*(?:\.\d+)?%?)\s*\)/gi; +} + +export function rgbRegex(options?: { exact?: boolean }): RegExp { + options = options || {}; + return options.exact + ? /^rgb\(\s*(\d{1,3})\s*[,\s]?\s*(\d{1,3})\s*[,\s]?\s*(\d{1,3})(?:\s*\/?\s*(\d*(?:\.\d+)?%?))?\s*\)$/ + : /rgb\(\s*(\d{1,3})\s*[,\s]?\s*(\d{1,3})\s*[,\s]?\s*(\d{1,3})(?:\s*\/?\s*(\d*(?:\.\d+)?%?))?\s*\)/gi; +} + +export function rgbaRegex(options?: { exact?: boolean }): RegExp { + options = options || {}; + return options.exact + ? /^rgba\(\s*(\d{1,3})\s*[,\s]?\s*(\d{1,3})\s*[,\s]?\s*(\d{1,3})\s*[,/]?\s*(\d*(?:\.\d+)?%?)\s*\)$/ + : /rgba\(\s*(\d{1,3})\s*[,\s]?\s*(\d{1,3})\s*[,\s]?\s*(\d{1,3})\s*[,/]?\s*(\d*(?:\.\d+)?%?)\s*\)/gi; +} + + +=== File: src/internal/css-defaults.ts === +// b_path:: src/internal/css-defaults.ts + +/** + * CSS default values for directional properties (kebab-case). + * Used for partial longhand expansion when expandPartialLonghand option is enabled. + */ +export const CSS_DEFAULTS: Record = { + // Border width + "border-top-width": "medium", + "border-right-width": "medium", + "border-bottom-width": "medium", + "border-left-width": "medium", + + // Border style + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + + // Border color + "border-top-color": "currentcolor", + "border-right-color": "currentcolor", + "border-bottom-color": "currentcolor", + "border-left-color": "currentcolor", + + // Border radius + "border-top-left-radius": "0", + "border-top-right-radius": "0", + "border-bottom-right-radius": "0", + "border-bottom-left-radius": "0", + + // Margin + "margin-top": "0", + "margin-right": "0", + "margin-bottom": "0", + "margin-left": "0", + + // Padding + "padding-top": "0", + "padding-right": "0", + "padding-bottom": "0", + "padding-left": "0", + + // Positioning + top: "auto", + right: "auto", + bottom: "auto", + left: "auto", + + // Background position + "background-position-x": "0%", + "background-position-y": "0%", + + // Mask position + "mask-position-x": "0%", + "mask-position-y": "0%", + + // Object position + "object-position-x": "50%", + "object-position-y": "50%", + + // Scroll margin + "scroll-margin-top": "0", + "scroll-margin-right": "0", + "scroll-margin-bottom": "0", + "scroll-margin-left": "0", + + // Scroll padding + "scroll-padding-top": "auto", + "scroll-padding-right": "auto", + "scroll-padding-bottom": "auto", + "scroll-padding-left": "auto", +}; + + +=== File: src/internal/directional.ts === +// b_path:: src/internal/directional.ts + +/** + * Cache for directional property expansion results. + * Improves performance for repeated calls with same values. + */ +const directionalCache = new Map>(); +const MAX_CACHE_SIZE = 1000; + +/** + * Expands directional values (top, right, bottom, left) from CSS shorthand notation. + * Supports 1-4 value syntax and caches results for performance. + * + * @param value - CSS value string with 1-4 space-separated values + * @returns Object with top, right, bottom, left properties, or undefined if invalid + * + * @example + * directional('10px') // { top: '10px', right: '10px', bottom: '10px', left: '10px' } + * directional('10px 20px') // { top: '10px', right: '20px', bottom: '10px', left: '20px' } + * directional('10px 20px 30px') // { top: '10px', right: '20px', bottom: '30px', left: '20px' } + * directional('10px 20px 30px 40px') // { top: '10px', right: '20px', bottom: '30px', left: '40px' } + */ +export default function directional(value: string): Record | undefined { + // Check cache first + const cached = directionalCache.get(value); + if (cached) return cached; + + const values = value.split(/\s+/); + + if (values.length === 1) values.splice(0, 1, ...Array.from({ length: 4 }, () => values[0])); + else if (values.length === 2) values.push(...values); + else if (values.length === 3) values.push(values[1]); + else if (values.length > 4) return; + + const result = ["top", "right", "bottom", "left"].reduce( + (acc: Record, direction: string, i: number) => { + acc[direction] = values[i]; + return acc; + }, + {} + ); + + // Cache result with size limit + if (directionalCache.size >= MAX_CACHE_SIZE) { + // Remove oldest entry (first key) + const firstKey = directionalCache.keys().next().value; + if (firstKey !== undefined) { + directionalCache.delete(firstKey); + } + } + directionalCache.set(value, result); + + return result; +} + + +=== File: src/internal/expand-directional.ts === +// b_path:: src/internal/expand-directional.ts + +import { CSS_DEFAULTS } from "./css-defaults"; + +const DIRECTIONAL_SIDES = ["top", "right", "bottom", "left"] as const; +const CORNER_POSITIONS = ["top-left", "top-right", "bottom-right", "bottom-left"] as const; + +/** + * Base key for grouping bare directional properties (top, right, bottom, left). + * These map to the CSS `inset` logical property group. + */ +const INSET_BASE_KEY = "inset"; + +/** + * Base key for grouping border-radius corner properties. + * These share the same "border-radius" shorthand. + */ +const BORDER_RADIUS_BASE_KEY = "border-radius"; + +/** + * Grouping information for side-based directional properties (top, right, bottom, left). + */ +interface DirectionalGroup { + prefix: string; + suffix: string; + sides: Set; +} + +/** + * Grouping information for corner-based properties (top-left, top-right, etc.). + */ +interface CornerGroup { + corners: Set; +} + +/** + * Builds a full CSS property name from prefix, side, and suffix components. + * + * @param prefix - Property prefix (e.g., "border-", "margin-", or "") + * @param side - Directional side (e.g., "top", "right", "bottom", "left") + * @param suffix - Property suffix (e.g., "-width", "-style", or "") + * @returns Full property name (e.g., "border-top-width", "margin-top", "top") + * + * @example + * buildPropertyName("border-", "top", "-width") // → "border-top-width" + * buildPropertyName("margin-", "top", "") // → "margin-top" + * buildPropertyName("", "top", "") // → "top" + */ +function buildPropertyName(prefix: string, side: string, suffix: string): string { + if (prefix === "" && suffix === "") { + // Just the side (top, right, bottom, left) + return side; + } + if (suffix === "") { + // prefix-side (e.g., "margin-top") + return `${prefix}${side}`; + } + // prefix-side-suffix (e.g., "border-top-width") + return `${prefix}${side}${suffix}`; +} + +/** + * Expands partial directional properties by filling in missing sides with CSS defaults. + * + * Scans the result for directional keywords (top, right, bottom, left), groups them by + * base property, and fills in any missing sides with their CSS default values. + * + * @param result - Object with CSS properties (kebab-case) + * @returns New object with expanded directional properties + * + * @example + * expandDirectionalProperties({ 'border-top-width': '1px' }) + * // → { + * // 'border-top-width': '1px', + * // 'border-right-width': 'medium', + * // 'border-bottom-width': 'medium', + * // 'border-left-width': 'medium' + * // } + */ +export function expandDirectionalProperties( + result: Record +): Record { + const groups = new Map(); + const cornerGroups = new Map(); + + // Pre-compile regex for directional property matching (more efficient than string operations) + // Matches: -(top|right|bottom|left)- or -(top|right|bottom|left)$ or exact side + const directionalRegex = /^(.*)-(top|right|bottom|left)(-(.*))?$|^(top|right|bottom|left)$/; + + // Detect directional properties and group by base + for (const property of Object.keys(result)) { + // Check for border-radius corner properties first + const cornerMatch = property.match( + /^border-(top-left|top-right|bottom-right|bottom-left)-radius$/ + ); + if (cornerMatch) { + const corner = cornerMatch[1]; + if (!cornerGroups.has(BORDER_RADIUS_BASE_KEY)) { + cornerGroups.set(BORDER_RADIUS_BASE_KEY, { corners: new Set() }); + } + cornerGroups.get(BORDER_RADIUS_BASE_KEY)!.corners.add(corner); + continue; + } + + // Check for side-based directional properties using pre-compiled regex + const match = property.match(directionalRegex); + if (match) { + // match[1] = prefix (if side in middle or end), match[2] = side (if hyphenated), + // match[4] = suffix (if side in middle), match[5] = bare side + const side = match[2] || match[5]; // Side from hyphenated or bare match + + if (!side) continue; // Shouldn't happen, but defensive check + + if (match[5]) { + // Bare side property (e.g., "top") + const prefix = ""; + const suffix = ""; + const baseKey = INSET_BASE_KEY; + + if (!groups.has(baseKey)) { + groups.set(baseKey, { prefix, suffix, sides: new Set() }); + } + groups.get(baseKey)!.sides.add(side); + } else if (match[4] !== undefined) { + // Side in the middle (e.g., border-top-width) + const prefix = match[1] ? `${match[1]}-` : ""; // Include trailing hyphen + const suffix = match[4] ? `-${match[4]}` : ""; // Include leading hyphen + const baseKey = `${prefix}|${suffix}`; // Normalized grouping key + + if (!groups.has(baseKey)) { + groups.set(baseKey, { prefix, suffix, sides: new Set() }); + } + groups.get(baseKey)!.sides.add(side); + } else { + // Side at the end (e.g., margin-top) + const prefix = match[1] ? `${match[1]}-` : ""; // Include trailing hyphen + const suffix = ""; + const baseKey = prefix || side; // Use prefix as key; fallback to side + + if (!groups.has(baseKey)) { + groups.set(baseKey, { prefix, suffix, sides: new Set() }); + } + groups.get(baseKey)!.sides.add(side); + } + } + } + + // If no directional groups found, return as-is + if (groups.size === 0 && cornerGroups.size === 0) { + return result; + } + + const expanded: Record = { ...result }; + + // Fill missing corners for border-radius + for (const [, group] of cornerGroups) { + const { corners } = group; + + // If all 4 corners present, nothing to expand + if (corners.size === 4) { + continue; + } + + // Add missing corners + for (const corner of CORNER_POSITIONS) { + if (!corners.has(corner)) { + const fullProperty = `border-${corner}-radius`; + const defaultValue = CSS_DEFAULTS[fullProperty]; + + if (defaultValue) { + expanded[fullProperty] = defaultValue; + } + } + } + } + + // Fill missing sides with defaults + for (const [, group] of groups) { + const { prefix, suffix, sides } = group; + + // If all 4 sides present, nothing to expand + if (sides.size === 4) { + continue; + } + + // Add missing sides + for (const side of DIRECTIONAL_SIDES) { + if (!sides.has(side)) { + const fullProperty = buildPropertyName(prefix, side, suffix); + const defaultValue = CSS_DEFAULTS[fullProperty]; + + if (defaultValue) { + expanded[fullProperty] = defaultValue; + } + } + } + } + + return expanded; +} + + +=== File: src/internal/grid-line.ts === +// b_path:: src/internal/grid-line.ts + +export function isCustomIdent(s: string): boolean { + return /^[a-zA-Z_-][a-zA-Z0-9_-]*$/.test(s); +} + +function isInteger(s: string): boolean { + return /^[+-]?[0-9]+$/.test(s) && Number(s) !== 0; +} + +export function parseGridLine(value: string): boolean { + const tokens = value.trim().split(/\s+/); + if (tokens.length === 0) return false; + + if (tokens[0] === "span") { + if (tokens.length < 2 || tokens.length > 3) return false; + const rest = tokens.slice(1); + let seenInt = false; + let seenIdent = false; + for (const token of rest) { + if (isInteger(token)) { + if (seenInt) return false; + seenInt = true; + } else if (isCustomIdent(token)) { + if (seenIdent) return false; + seenIdent = true; + } else { + return false; + } + } + return seenInt || seenIdent; + } else { + if (tokens.length > 2) return false; + let seenInt = false; + let seenIdent = false; + for (const token of tokens) { + if (isInteger(token)) { + if (seenInt) return false; + seenInt = true; + } else if (isCustomIdent(token)) { + if (seenIdent) return false; + seenIdent = true; + } else { + return false; + } + } + return seenInt || seenIdent; + } +} + +export function getDefaultEnd(start: string): string { + return isCustomIdent(start) ? start : "auto"; +} + + +=== File: src/internal/handler-registry.ts === +// b_path:: src/internal/handler-registry.ts + +/** + * Centralized registry for all PropertyHandler instances. + * Enables dynamic property lookup, introspection, and foundation for collapse API. + * @module handler-registry + */ + +// Import all handlers +import { animationHandler } from "../handlers/animation"; +import { backgroundHandler } from "../handlers/background"; +import { backgroundPositionHandler } from "../handlers/background-position"; +import { borderHandler } from "../handlers/border"; +import { borderBlockHandler } from "../handlers/border-block"; +import { borderBlockEndHandler } from "../handlers/border-block-end"; +import { borderBlockStartHandler } from "../handlers/border-block-start"; +import { borderBottomHandler } from "../handlers/border-bottom"; +import { borderColorHandler } from "../handlers/border-color"; +import { borderImageHandler } from "../handlers/border-image"; +import { borderInlineHandler } from "../handlers/border-inline"; +import { borderInlineEndHandler } from "../handlers/border-inline-end"; +import { borderInlineStartHandler } from "../handlers/border-inline-start"; +import { borderLeftHandler } from "../handlers/border-left"; +import { borderRadiusHandler } from "../handlers/border-radius"; +import { borderRightHandler } from "../handlers/border-right"; +import { borderStyleHandler } from "../handlers/border-style"; +import { borderTopHandler } from "../handlers/border-top"; +import { borderWidthHandler } from "../handlers/border-width"; +import { columnRuleHandler } from "../handlers/column-rule"; +import { columnsHandler } from "../handlers/columns"; +import { containIntrinsicSizeHandler } from "../handlers/contain-intrinsic-size"; +import { containerHandler } from "../handlers/container"; +import { flexHandler } from "../handlers/flex"; +import { flexFlowHandler } from "../handlers/flex-flow"; +import { fontHandler } from "../handlers/font"; +import { gapHandler } from "../handlers/gap"; +import { gridHandler } from "../handlers/grid"; +import { gridAreaHandler } from "../handlers/grid-area"; +import { gridColumnHandler } from "../handlers/grid-column"; +import { gridRowHandler } from "../handlers/grid-row"; +import { gridTemplateHandler } from "../handlers/grid-template"; +import { insetHandler } from "../handlers/inset"; +import { insetBlockHandler } from "../handlers/inset-block"; +import { insetInlineHandler } from "../handlers/inset-inline"; +import { listStyleHandler } from "../handlers/list-style"; +import { marginHandler } from "../handlers/margin"; +import { maskHandler } from "../handlers/mask"; +import { maskPositionHandler } from "../handlers/mask-position"; +import { objectPositionHandler } from "../handlers/object-position"; +import { offsetHandler } from "../handlers/offset"; +import { outlineHandler } from "../handlers/outline"; +import { overflowHandler } from "../handlers/overflow"; +import { paddingHandler } from "../handlers/padding"; +import { placeContentHandler } from "../handlers/place-content"; +import { placeItemsHandler } from "../handlers/place-items"; +import { placeSelfHandler } from "../handlers/place-self"; +import { scrollMarginHandler } from "../handlers/scroll-margin"; +import { scrollPaddingHandler } from "../handlers/scroll-padding"; +import { textDecorationHandler } from "../handlers/text-decoration"; +import { textEmphasisHandler } from "../handlers/text-emphasis"; +import { transitionHandler } from "../handlers/transition"; +import type { PropertyCategory, PropertyHandler, PropertyHandlerOptions } from "./property-handler"; + +/** + * Handler registry interface providing centralized access to all PropertyHandlers. + */ +export interface HandlerRegistry { + /** Read-only map of all registered handlers */ + readonly handlers: ReadonlyMap; + + /** Get a handler by shorthand property name */ + get(shorthand: string): PropertyHandler | undefined; + + /** Check if a shorthand property is registered */ + has(shorthand: string): boolean; + + /** Get all handlers in a specific category */ + getByCategory(category: PropertyCategory): PropertyHandler[]; + + /** Get all registered shorthand property names */ + getAllShorthands(): string[]; + + /** Get longhand properties for a shorthand */ + getLonghands(shorthand: string): string[] | undefined; + + /** Get all shorthands that include a specific longhand */ + getShorthandsForLonghand(longhand: string): string[]; +} + +/** + * Internal handler map with all 25 PropertyHandlers. + * @internal + */ +const handlerMap = new Map([ + ["animation", animationHandler], + ["background", backgroundHandler], + ["background-position", backgroundPositionHandler], + ["border", borderHandler], + ["border-block", borderBlockHandler], + ["border-block-end", borderBlockEndHandler], + ["border-block-start", borderBlockStartHandler], + ["border-bottom", borderBottomHandler], + ["border-color", borderColorHandler], + ["border-image", borderImageHandler], + ["border-inline", borderInlineHandler], + ["border-inline-end", borderInlineEndHandler], + ["border-inline-start", borderInlineStartHandler], + ["border-left", borderLeftHandler], + ["border-radius", borderRadiusHandler], + ["border-right", borderRightHandler], + ["border-style", borderStyleHandler], + ["border-top", borderTopHandler], + ["border-width", borderWidthHandler], + ["column-rule", columnRuleHandler], + ["columns", columnsHandler], + ["contain-intrinsic-size", containIntrinsicSizeHandler], + ["container", containerHandler], + ["flex", flexHandler], + ["flex-flow", flexFlowHandler], + ["font", fontHandler], + ["gap", gapHandler], + ["grid", gridHandler], + ["grid-area", gridAreaHandler], + ["grid-column", gridColumnHandler], + ["grid-row", gridRowHandler], + ["grid-template", gridTemplateHandler], + ["inset", insetHandler], + ["inset-block", insetBlockHandler], + ["inset-inline", insetInlineHandler], + ["list-style", listStyleHandler], + ["margin", marginHandler], + ["mask", maskHandler], + ["mask-position", maskPositionHandler], + ["object-position", objectPositionHandler], + ["offset", offsetHandler], + ["outline", outlineHandler], + ["overflow", overflowHandler], + ["padding", paddingHandler], + ["place-content", placeContentHandler], + ["place-items", placeItemsHandler], + ["place-self", placeSelfHandler], + ["scroll-margin", scrollMarginHandler], + ["scroll-padding", scrollPaddingHandler], + ["text-decoration", textDecorationHandler], + ["text-emphasis", textEmphasisHandler], + ["transition", transitionHandler], +]); + +/** + * Reverse index: longhand → shorthands[] + * Built once at module load time for O(1) lookups. + * @internal + */ +const longhandToShorthandsMap = new Map(); + +// Build reverse index +for (const [shorthand, handler] of handlerMap.entries()) { + for (const longhand of handler.meta.longhands) { + const existing = longhandToShorthandsMap.get(longhand) ?? []; + existing.push(shorthand); + longhandToShorthandsMap.set(longhand, existing); + } +} + +/** + * Global handler registry instance. + * + * Provides centralized access to all PropertyHandler instances, enabling: + * - Dynamic property expansion + * - Handler introspection + * - Reverse longhand → shorthand lookups + * - Category-based filtering + * + * @example + * ```typescript + * import { registry } from 'b_short'; + * + * // Get a handler + * const handler = registry.get('overflow'); + * + * // Check availability + * if (registry.has('flex-flow')) { + * const longhands = registry.getLonghands('flex-flow'); + * } + * + * // Get by category + * const layoutHandlers = registry.getByCategory('layout'); + * ``` + */ +export const registry: HandlerRegistry = { + handlers: handlerMap, + + get(shorthand: string): PropertyHandler | undefined { + return handlerMap.get(shorthand); + }, + + has(shorthand: string): boolean { + return handlerMap.has(shorthand); + }, + + getByCategory(category: PropertyCategory): PropertyHandler[] { + return Array.from(handlerMap.values()).filter((h) => h.meta.category === category); + }, + + getAllShorthands(): string[] { + return Array.from(handlerMap.keys()); + }, + + getLonghands(shorthand: string): string[] | undefined { + return handlerMap.get(shorthand)?.meta.longhands; + }, + + getShorthandsForLonghand(longhand: string): string[] { + return longhandToShorthandsMap.get(longhand) ?? []; + }, +}; + +/** + * Dynamically expand a CSS shorthand property value. + * + * @param property - The shorthand property name (e.g., 'overflow', 'flex-flow') + * @param value - The CSS value to expand + * @param options - Optional handler options + * @returns Expanded longhand properties, or undefined if expansion fails + * + * @example + * ```typescript + * import { expandProperty } from 'b_short'; + * + * const result = expandProperty('overflow', 'hidden auto'); + * // { 'overflow-x': 'hidden', 'overflow-y': 'auto' } + * + * const invalid = expandProperty('overflow', 'not-valid'); + * // undefined + * ``` + */ +export function expandProperty( + property: string, + value: string, + options?: PropertyHandlerOptions +): Record | undefined { + const handler = registry.get(property); + return handler?.expand(value, options); +} + +/** + * Validate a CSS shorthand property value without expanding it. + * + * @param property - The shorthand property name + * @param value - The CSS value to validate + * @returns true if valid, false otherwise + * + * @example + * ```typescript + * import { validateProperty } from 'b_short'; + * + * validateProperty('overflow', 'hidden'); // true + * validateProperty('overflow', 'invalid'); // false + * validateProperty('unknown-property', 'value'); // false + * ``` + */ +export function validateProperty(property: string, value: string): boolean { + const handler = registry.get(property); + return handler?.validate?.(value) ?? false; +} + +/** + * Check if a CSS property is a registered shorthand. + * + * @param property - The property name to check + * @returns true if the property is a registered shorthand + * + * @example + * ```typescript + * import { isShorthandProperty } from 'b_short'; + * + * isShorthandProperty('overflow'); // true + * isShorthandProperty('overflow-x'); // false (longhand) + * isShorthandProperty('unknown'); // false + * ``` + */ +export function isShorthandProperty(property: string): boolean { + return registry.has(property); +} + + +=== File: src/internal/is-angle.ts === +// b_path:: src/internal/is-angle.ts +const ANGLE = /^(\+|-)?([0-9]*\.?[0-9]+)(deg|rad|turn|grad)$/i; +const ZERO = /^(\+|-)?(0*\.)?0+$/; + +export default function isAngle(value: string): boolean { + return ANGLE.test(value) || ZERO.test(value); +} + + +=== File: src/internal/is-color.ts === +// b_path:: src/internal/is-color.ts +import { + CSS_COLOR_NAMES, + hexColorRegex, + hslaRegex, + hslRegex, + rgbaRegex, + rgbRegex, +} from "./color-utils"; + +const HEX = new RegExp(`^${hexColorRegex().source}$`, "i"); +const HSLA = hslaRegex({ exact: true }); +const HSL = hslRegex({ exact: true }); +const RGB = rgbRegex({ exact: true }); +const RGBA = rgbaRegex({ exact: true }); + +/** + * Cache for color validation results. + * Improves performance for repeated color checks. + */ +const colorCache = new Map(); +const MAX_CACHE_SIZE = 500; + +/** + * Checks if a string value represents a valid CSS color. + * Supports named colors, hex, rgb, rgba, hsl, hsla, and CSS custom properties. + * Results are cached for performance. + * + * @param value - The CSS value to check + * @returns true if the value is a valid color, false otherwise + * + * @example + * isColor('red') // true + * isColor('#ff0000') // true + * isColor('rgb(255, 0, 0)') // true + * isColor('var(--primary-color)') // true + * isColor('10px') // false + */ +export default function isColor(value: string): boolean { + // Check cache first + const cached = colorCache.get(value); + if (cached !== undefined) return cached; + + const lowerValue = value.toLowerCase(); + + // Support CSS custom property (var name) + if (/^var\(\s*--[a-zA-Z0-9-_]+\s*\)$/.test(lowerValue)) { + colorCache.set(value, true); + return true; + } + + const result = + !!CSS_COLOR_NAMES[lowerValue] || + lowerValue === "currentcolor" || + lowerValue === "transparent" || + HEX.test(lowerValue) || + HSLA.test(lowerValue) || + HSL.test(lowerValue) || + RGB.test(lowerValue) || + RGBA.test(lowerValue); + + // Cache result with size limit + if (colorCache.size >= MAX_CACHE_SIZE) { + const firstKey = colorCache.keys().next().value; + if (firstKey !== undefined) { + colorCache.delete(firstKey); + } + } + colorCache.set(value, result); + + return result; +} + + +=== File: src/internal/is-length.ts === +// b_path:: src/internal/is-length.ts +const LENGTH = /^(\+|-)?([0-9]*\.)?[0-9]+(em|ex|ch|rem|vh|vw|vmin|vmax|px|mm|cm|in|pt|pc|%)$/i; +const ZERO = /^(\+|-)?(0*\.)?0+$/; + +export default function isLength(value: string): boolean { + return LENGTH.test(value) || ZERO.test(value); +} + + +=== File: src/internal/is-time.ts === +// b_path:: src/internal/is-time.ts +// Utility to detect CSS time values for transition-duration and transition-delay parsing +const TIME = /^[+-]?(\d*\.)?\d+(m?s)$/i; + +export default function isTime(value: string): boolean { + return TIME.test(value); +} + + +=== File: src/internal/is-timing-function.ts === +// b_path:: src/internal/is-timing-function.ts +// Utility to detect CSS timing function values for transition-timing-function parsing +const TIMING_KEYWORDS = /^(ease|linear|ease-in|ease-out|ease-in-out|step-start|step-end)$/i; +const TIMING_FUNCTIONS = /^(cubic-bezier|steps)\s*\(/i; + +function hasBalancedParentheses(value: string): boolean { + let openCount = 0; + for (const char of value) { + if (char === "(") { + openCount++; + } else if (char === ")") { + openCount--; + if (openCount < 0) { + return false; + } + } + } + return openCount === 0; +} + +export default function isTimingFunction(value: string): boolean { + // Check for timing function keywords + if (TIMING_KEYWORDS.test(value)) { + return true; + } + + // Check for timing function functions with balanced parentheses + if (TIMING_FUNCTIONS.test(value)) { + return hasBalancedParentheses(value); + } + + return false; +} + + +=== File: src/internal/is-value-node.ts === +// b_path:: src/internal/is-value-node.ts +import type * as csstree from "@eslint/css-tree"; + +/** + * CSS functions that represent colors, not numeric values. + * These should NOT be accepted as position/size values. + */ +const COLOR_FUNCTIONS = new Set([ + "rgb", + "rgba", + "hsl", + "hsla", + "hwb", + "lab", + "lch", + "oklab", + "oklch", + "color", + "color-mix", + "light-dark", +]); + +/** + * CSS functions that represent images, not numeric values. + * These should NOT be accepted as position/size values. + */ +const IMAGE_FUNCTIONS = new Set([ + "url", + "image", + "image-set", + "element", + "linear-gradient", + "radial-gradient", + "conic-gradient", + "repeating-linear-gradient", + "repeating-radial-gradient", + "repeating-conic-gradient", + "cross-fade", + "paint", +]); + +/** + * Universal type checker that handles CSS functions intelligently. + * + * Use this instead of direct `node.type === "Dimension"` checks. + * Automatically accepts calc(), var(), min(), max(), and all math functions. + * + * @example + * // OLD: if (child.type === "Dimension" || child.type === "Percentage") + * // NEW: if (matchesType(child, ["Dimension", "Percentage"])) + * + * @internal + */ +export function matchesType(node: csstree.CssNode | undefined, types: string | string[]): boolean { + if (!node) return false; + + const typeArray = Array.isArray(types) ? types : [types]; + const nodeType = node.type; + + // Direct type match + if (typeArray.includes(nodeType)) { + return true; + } + + // Function node: treat as numeric value unless it's a color/image function + if ( + nodeType === "Function" && + (typeArray.includes("Dimension") || + typeArray.includes("Percentage") || + typeArray.includes("Number")) + ) { + const funcName = (node as csstree.FunctionNode).name.toLowerCase(); + return !COLOR_FUNCTIONS.has(funcName) && !IMAGE_FUNCTIONS.has(funcName); + } + + return false; +} + +/** + * Checks if a css-tree node is a valid numeric/dimensional value type. + * Accepts: Dimension, Percentage, Number, Function (calc, var, etc.) + * Rejects: Color functions (rgb, hsl), Image functions (url, gradient) + * + * Use this for properties that accept length, percentage, or calculated values. + * + * @internal + */ +export function isNumericValueNode(node: csstree.CssNode): boolean { + return matchesType(node, ["Dimension", "Percentage", "Number"]); +} + +/** + * Checks if a css-tree node is a valid position value type. + * Accepts: Identifier (keywords), Dimension, Percentage, Number, Function + * + * Use this for properties like background-position, mask-position, etc. + * + * @param node - css-tree node to check + * @param keywords - Optional array of valid identifier keywords (e.g., ['left', 'center', 'right']) + * @internal + */ +export function isPositionValueNode(node: csstree.CssNode, keywords?: string[]): boolean { + if (isNumericValueNode(node)) { + return true; + } + + if (node.type === "Identifier") { + if (!keywords) return true; + return keywords.includes((node as csstree.Identifier).name); + } + + return false; +} + +/** + * Checks if a css-tree node is a valid size value type. + * Accepts: Identifier (keywords), Dimension, Percentage, Number, Function + * + * Use this for properties like background-size, mask-size, width, height, etc. + * + * @param node - css-tree node to check + * @param keywords - Optional array of valid identifier keywords (e.g., ['auto', 'cover', 'contain']) + * @internal + */ +export function isSizeValueNode(node: csstree.CssNode, keywords?: string[]): boolean { + return isPositionValueNode(node, keywords); +} + + +=== File: src/internal/layer-parser-utils.ts === +// b_path:: src/internal/layer-parser-utils.ts +import * as csstree from "@eslint/css-tree"; + +/** + * Shared utilities for parsing multi-layer CSS properties (background, mask, animation, transition). + * Eliminates ~360 lines of duplication across layer-parsing modules. + */ + +/** + * Detects if a CSS value contains top-level commas (indicating multiple layers). + * Ignores commas inside parentheses/brackets (functions, rgba(), etc.). + * + * @param value - CSS value to analyze + * @param detectFunctions - If true, also return true for values with function calls + * @returns true if multi-layer parsing is needed + * + * @example + * hasTopLevelCommas('url(a.png), url(b.png)') // → true (multiple layers) + * hasTopLevelCommas('rgba(0,0,0,0.5)') // → false (comma inside function) + * hasTopLevelCommas('cubic-bezier(0,0,1,1)', true) // → true (has function) + */ +export function hasTopLevelCommas(value: string, detectFunctions = false): boolean { + let parenDepth = 0; + let bracketDepth = 0; + let hasFunctions = false; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + + if (char === "(") { + parenDepth++; + hasFunctions = true; + } else if (char === ")") { + parenDepth--; + } else if (char === "[") { + bracketDepth++; + } else if (char === "]") { + bracketDepth--; + } else if (char === "," && parenDepth === 0 && bracketDepth === 0) { + // Found a comma at the top level - this indicates multiple layers + return true; + } + } + + // Optionally detect functions (for animation/transition timing functions) + return detectFunctions && hasFunctions; +} + +/** + * Splits a CSS value into layers at top-level commas, respecting nested functions. + * + * @param value - CSS value to split + * @returns Array of layer strings (trimmed) + * + * @example + * splitLayers('url(a.png) center, url(b.png) top') + * // → ['url(a.png) center', 'url(b.png) top'] + * + * splitLayers('rgba(0,0,0,0.5)') + * // → ['rgba(0,0,0,0.5)'] + */ +export function splitLayers(value: string): string[] { + const layers: string[] = []; + let currentLayer = ""; + let parenDepth = 0; + let bracketDepth = 0; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + + if (char === "(") { + parenDepth++; + } else if (char === ")") { + parenDepth--; + } else if (char === "[") { + bracketDepth++; + } else if (char === "]") { + bracketDepth--; + } else if (char === "," && parenDepth === 0 && bracketDepth === 0) { + // Found a comma at the top level - this separates layers + layers.push(currentLayer.trim()); + currentLayer = ""; + continue; + } + + currentLayer += char; + } + + // Add the last layer + if (currentLayer.trim()) { + layers.push(currentLayer.trim()); + } + + return layers; +} + +/** + * Generic layer parsing factory for multi-layer CSS properties. + * Handles splitting, parsing, and error handling uniformly. + * + * @param value - CSS value to parse + * @param parseSingleLayer - Function to parse a single layer string + * @returns Parsed layers or undefined on error + * + * @example + * // Background parsing + * parseLayersGeneric( + * 'url(a.png) center, url(b.png) top', + * parseBackgroundLayer + * ) + */ +export function parseLayersGeneric( + value: string, + parseSingleLayer: (layerValue: string) => T | undefined +): T[] | undefined { + try { + // Split into layers + const layerStrings = splitLayers(value); + if (layerStrings.length === 0) { + return undefined; + } + + // Parse each layer + const layers: T[] = []; + for (const layerStr of layerStrings) { + const parsedLayer = parseSingleLayer(layerStr); + if (!parsedLayer) { + return undefined; // Parsing failed for this layer + } + layers.push(parsedLayer); + } + + return layers; + } catch (_error) { + // If parsing fails, return undefined to indicate invalid input + return undefined; + } +} + +/** + * Collects all child nodes from a css-tree AST Value node. + * Common pattern across all multi-layer parsers. + * + * @param ast - css-tree AST (must contain Value nodes) + * @returns Flattened array of child nodes + */ +export function collectCssTreeChildren(ast: csstree.CssNode): csstree.CssNode[] { + const children: csstree.CssNode[] = []; + + // Type guard to ensure we have a valid css-tree node + if (!ast || typeof ast !== "object") { + return children; + } + + // Walk the AST and collect children from Value nodes + csstree.walk(ast, { + visit: "Value", + enter: (node: csstree.CssNode) => { + if (node.type === "Value" && node.children) { + for (const child of node.children) { + children.push(child); + } + } + }, + }); + + return children; +} + + +=== File: src/internal/normalize-color.ts === +// b_path:: src/internal/normalize-color.ts +import { hslaRegex, hslRegex, rgbaRegex, rgbRegex } from "./color-utils"; + +const FUNCTIONS = [hslaRegex(), hslRegex(), rgbRegex(), rgbaRegex()]; + +export default function normalizeColor(value: string): string { + return FUNCTIONS.reduce((acc: string, func: RegExp) => { + return acc.replace(func, (match: string) => { + // Normalize both modern (space-separated) and legacy (comma-separated) syntax + // Convert space-separated to comma-separated for consistent handling + return match + .replace(/\(\s+/g, "(") // Remove space after opening paren + .replace(/\s+\)/g, ")") // Remove space before closing paren + .replace(/\s*,\s*/g, ",") // Normalize commas (remove spaces around them) + .replace(/\s*\/\s*/g, "/") // Normalize slash (remove spaces around it) + .replace(/\s+/g, ","); // Convert remaining spaces (separators) to commas + }); + }, value); +} + + +=== File: src/internal/parsers.ts === +// b_path:: src/internal/parsers.ts + +/** + * Internal parsing utilities for CSS declaration processing. + * @internal + */ + +/** + * Removes all CSS comments from the input string. + * Uses a character-by-character scanning approach that safely handles multi-line comments. + * @internal + */ +export function stripComments(css: string): string { + let result = ""; + let i = 0; + + while (i < css.length) { + if (css[i] === "/" && css[i + 1] === "*") { + let j = i + 2; + while (j < css.length) { + if (css[j] === "*" && css[j + 1] === "/") { + result += " "; + i = j + 2; + break; + } + j++; + } + if (j >= css.length) { + i = css.length; + } + } else { + result += css[i]; + i++; + } + } + + return result; +} + +/** + * Parses CSS input string into individual declarations. + * Handles quotes, parentheses, and brackets correctly. + * @internal + */ +export function parseInputString(input: string): string[] { + const declarations: string[] = []; + let current = ""; + let i = 0; + + while (i < input.length) { + const char = input[i]; + const nextChar = input[i + 1]; + + if (char === "\\" && nextChar) { + current += char + nextChar; + i += 2; + continue; + } + + if (char === '"' || char === "'") { + let quoteEnd = i + 1; + while (quoteEnd < input.length) { + if (input[quoteEnd] === char && input[quoteEnd - 1] !== "\\") { + break; + } + quoteEnd++; + } + if (quoteEnd < input.length) { + current += input.substring(i, quoteEnd + 1); + i = quoteEnd + 1; + continue; + } + } + + if (char === "(") { + let parenCount = 1; + let parenEnd = i + 1; + while (parenEnd < input.length && parenCount > 0) { + if (input[parenEnd] === "(") parenCount++; + if (input[parenEnd] === ")") parenCount--; + parenEnd++; + } + if (parenCount === 0) { + current += input.substring(i, parenEnd); + i = parenEnd; + continue; + } + } + + if (char === "[") { + const bracketEnd = input.indexOf("]", i + 1); + if (bracketEnd !== -1) { + current += input.substring(i, bracketEnd + 1); + i = bracketEnd + 1; + continue; + } + } + + if (char === ";") { + declarations.push(current.trim()); + current = ""; + } else { + current += char; + } + + i++; + } + + if (current.trim()) { + declarations.push(current.trim()); + } + + return declarations.filter((decl) => decl.length > 0); +} + +/** + * Parses a CSS declaration into property and value. + * @internal + */ +export function parseCssDeclaration( + declaration: string +): { property: string; value: string } | null { + const trimmed = declaration.trim(); + const colonIndex = trimmed.indexOf(":"); + + if (colonIndex === -1) return null; + + const property = trimmed.slice(0, colonIndex).trim(); + const value = trimmed.slice(colonIndex + 1).trim(); + + if (!property || !value) return null; + + return { property, value }; +} + + +=== File: src/internal/place-utils.ts === +// b_path:: src/internal/place-utils.ts +export function consolidatePlaceTokens( + value: string, + nextTokenPattern: RegExp +): string[] | undefined { + const tokens = value.trim().split(/\s+/); + const out: string[] = []; + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]; + if (/^(first|last)$/i.test(t) && i + 1 < tokens.length && /^baseline$/i.test(tokens[i + 1])) { + out.push(`${tokens[i]} ${tokens[i + 1]}`); + i++; + continue; + } + if ( + /^(safe|unsafe)$/i.test(t) && + i + 1 < tokens.length && + nextTokenPattern.test(tokens[i + 1]) + ) { + out.push(`${tokens[i]} ${tokens[i + 1]}`); + i++; + continue; + } + out.push(t); + } + return out.length <= 2 ? out : undefined; +} + + +=== File: src/internal/position-parser.ts === +// b_path:: src/internal/position-parser.ts + +/** + * Position parser utility for CSS position shorthands + * + * Handles: background-position, mask-position, object-position + * Grammar: [ left | center | right | top | bottom | ] + * or [ [ left | center | right ] [ top | center | bottom ] ] + * or [ [ left | center | right | ] [ top | center | bottom | ] ] + */ + +export interface PositionResult { + x: string; + y: string; +} + +const POSITION_KEYWORDS = new Set(["left", "center", "right", "top", "bottom"]); +const HORIZONTAL_KEYWORDS = new Set(["left", "center", "right"]); +const VERTICAL_KEYWORDS = new Set(["top", "center", "bottom"]); + +/** + * Parse a CSS position value into x and y components + */ +export function parsePosition(value: string): PositionResult { + const trimmed = value.trim(); + + // Empty string defaults to 0% 0% + if (!trimmed) { + return { x: "0%", y: "0%" }; + } + + // Handle global keywords + if ( + trimmed === "initial" || + trimmed === "inherit" || + trimmed === "unset" || + trimmed === "revert" + ) { + return { x: trimmed, y: trimmed }; + } + + // Split by whitespace (but preserve calc(), var(), etc.) + const parts = smartSplit(trimmed); + + if (parts.length === 0) { + return { x: "0%", y: "0%" }; + } + + // 1-value syntax + if (parts.length === 1) { + const part = parts[0]; + + // If it's a vertical keyword, treat as y-only + if (VERTICAL_KEYWORDS.has(part)) { + return { x: "center", y: part }; + } + + // If it's a horizontal keyword or length, treat as x-only + if (HORIZONTAL_KEYWORDS.has(part) || !POSITION_KEYWORDS.has(part)) { + return { x: part, y: "center" }; + } + + // Unknown keyword + return { x: part, y: "center" }; + } + + // 2-value syntax + if (parts.length === 2) { + const [first, second] = parts; + + // Both are keywords + if (POSITION_KEYWORDS.has(first) && POSITION_KEYWORDS.has(second)) { + // Check if they're swapped (top/bottom first) + if (VERTICAL_KEYWORDS.has(first) && HORIZONTAL_KEYWORDS.has(second)) { + return { x: second, y: first }; + } + return { x: first, y: second }; + } + + // Standard case: x y + return { x: first, y: second }; + } + + // 3-value syntax: side offset center (edge-offset syntax) + if (parts.length === 3) { + const [first, second, third] = parts; + + // Pattern: left 10px center → x: left 10px, y: center + if (HORIZONTAL_KEYWORDS.has(first) && !POSITION_KEYWORDS.has(second)) { + return { x: `${first} ${second}`, y: third }; + } + + // Pattern: center top 10px → x: center, y: top 10px + if (VERTICAL_KEYWORDS.has(second) && !POSITION_KEYWORDS.has(third)) { + return { x: first, y: `${second} ${third}` }; + } + + // Pattern: center bottom 10px → x: center, y: bottom 10px + if (!POSITION_KEYWORDS.has(first) && VERTICAL_KEYWORDS.has(second)) { + return { x: first, y: `${second} ${third}` }; + } + + // Fallback: first is keyword, second is offset + if (HORIZONTAL_KEYWORDS.has(first)) { + return { x: `${first} ${second}`, y: third }; + } + + return { x: first, y: `${second} ${third}` }; + } + + // 4-value syntax: side offset side offset + if (parts.length === 4) { + const [first, second, third, fourth] = parts; + + // Determine which pair is horizontal vs vertical based on first keyword + if (HORIZONTAL_KEYWORDS.has(first)) { + // first+second is horizontal, third+fourth is vertical + return { x: `${first} ${second}`, y: `${third} ${fourth}` }; + } + // first+second is vertical, third+fourth is horizontal + return { x: `${third} ${fourth}`, y: `${first} ${second}` }; + } + + // Fallback for complex cases + return { x: parts[0], y: parts[1] || "center" }; +} + +/** + * Smart split that preserves function calls like calc(), var() + */ +function smartSplit(value: string): string[] { + const result: string[] = []; + let current = ""; + let depth = 0; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + + if (char === "(") { + depth++; + current += char; + } else if (char === ")") { + depth--; + current += char; + } else if (char === " " && depth === 0) { + if (current.trim()) { + result.push(current.trim()); + current = ""; + } + } else { + current += char; + } + } + + if (current.trim()) { + result.push(current.trim()); + } + + return result; +} + + +=== File: src/internal/property-handler.ts === +// b_path:: src/internal/property-handler.ts + +/** + * Options for property handler behavior + */ +export interface PropertyHandlerOptions { + /** Enable strict validation mode (reject invalid values). Default: false */ + strict?: boolean; + /** Preserve custom properties (CSS variables) in output. Default: true */ + preserveCustomProperties?: boolean; +} + +/** + * Default values for PropertyHandlerOptions + */ +export const DEFAULT_PROPERTY_HANDLER_OPTIONS: Required = { + strict: false, + preserveCustomProperties: true, +}; + +/** + * Property category enumeration + */ +export const PROPERTY_CATEGORIES = [ + "box-model", + "visual", + "layout", + "animation", + "typography", + "border", + "grid", + "position", + "positioning", + "other", +] as const; + +export type PropertyCategory = (typeof PROPERTY_CATEGORIES)[number]; + +/** + * Metadata describing a property handler + */ +export interface PropertyHandlerMetadata { + /** The shorthand property name (e.g., 'border') */ + shorthand: string; + /** Array of longhand property names this shorthand expands to */ + longhands: string[]; + /** Default values for longhand properties when not specified */ + defaults?: Record; + /** Property category classification */ + category: PropertyCategory; +} + +/** + * Property handler interface for CSS shorthand expansion + * + * All property handlers must implement this interface to ensure: + * - Consistent API across all handlers + * - Type-safe expansion logic + * - Introspection capabilities + * - Future extensibility (e.g., collapse API) + */ +export interface PropertyHandler { + /** + * Metadata describing the handler's properties + */ + readonly meta: PropertyHandlerMetadata; + + /** + * Expand a CSS shorthand value into its longhand properties + * + * @param value - The CSS value to expand + * @param options - Optional handler options + * @returns Object mapping longhand property names to values, or undefined if invalid + * + * @example + * ```typescript + * const handler = overflowHandler; + * handler.expand('hidden auto'); // { 'overflow-x': 'hidden', 'overflow-y': 'auto' } + * handler.expand('invalid'); // undefined + * ``` + */ + expand(value: string, options?: PropertyHandlerOptions): Record | undefined; + + /** + * Optional: Validate a CSS value without expanding it + * + * @param value - The CSS value to validate + * @returns true if valid, false otherwise + */ + validate?(value: string): boolean; + + /** + * Optional: Reconstruct a shorthand value from longhand properties (future feature) + * + * This method enables the "collapse API" - converting longhand properties back + * into their shorthand equivalent. + * + * @param properties - Object mapping longhand property names to values + * @returns The reconstructed shorthand value, or undefined if cannot be collapsed + * + * @example + * ```typescript + * const handler = overflowHandler; + * handler.reconstruct?.({ 'overflow-x': 'hidden', 'overflow-y': 'hidden' }); // 'hidden' + * handler.reconstruct?.({ 'overflow-x': 'hidden', 'overflow-y': 'auto' }); // 'hidden auto' + * ``` + */ + reconstruct?(properties: Record): string | undefined; + + /** + * Optional: Sub-handlers for related shorthands + * + * Some properties like 'border' have sub-properties (border-width, border-style, etc.) + * that are themselves shorthands. This allows hierarchical composition. + * + * @example + * ```typescript + * const borderHandler: PropertyHandler = { + * meta: { shorthand: 'border', ... }, + * expand: (value) => { ... }, + * handlers: { + * width: borderWidthHandler, + * style: borderStyleHandler, + * color: borderColorHandler, + * } + * }; + * ``` + */ + readonly handlers?: Readonly>; +} + +/** + * Factory function for creating property handlers with consistent behavior + * + * This factory wraps handler logic with: + * - Option validation and defaults + * - Error handling (returns undefined on exceptions) + * - Consistent return types + * + * @param config - Property handler configuration + * @returns A fully-formed PropertyHandler instance + * + * @example + * ```typescript + * export const overflowHandler = createPropertyHandler({ + * meta: { + * shorthand: 'overflow', + * longhands: ['overflow-x', 'overflow-y'], + * category: 'visual', + * }, + * expand: (value) => { + * // Handler implementation + * }, + * }); + * ``` + */ +export function createPropertyHandler(config: PropertyHandler): PropertyHandler { + return { + ...config, + expand: ( + value: string, + options?: PropertyHandlerOptions + ): Record | undefined => { + try { + // Apply default options + const validatedOptions: Required = { + ...DEFAULT_PROPERTY_HANDLER_OPTIONS, + ...options, + }; + + // Call the underlying expand function with validated options + return config.expand(value, validatedOptions); + } catch (_error) { + // Return undefined on any errors (invalid options, parsing failures, etc.) + return undefined; + } + }, + }; +} + + +=== File: src/internal/property-sorter.ts === +// b_path:: src/internal/property-sorter.ts + +import type { PropertyGrouping } from "../core/schema"; +import { GROUPING_BY_PROPERTY } from "../core/schema"; + +/** + * Internal property sorting utilities. + * @internal + */ + +/** + * CSS property ordering map - defines the canonical order of properties + * @internal + */ +export const PROPERTY_ORDER_MAP: Record = { + // Grid properties (indices 0-11) + "grid-row-start": 0, + "grid-row-end": 1, + "grid-column-start": 2, + "grid-column-end": 3, + "grid-template-rows": 4, + "grid-template-columns": 5, + "grid-template-areas": 6, + "grid-auto-rows": 7, + "grid-auto-columns": 8, + "grid-auto-flow": 9, + "row-gap": 10, + "column-gap": 11, + + // Animation properties (indices 20-27) + "animation-name": 20, + "animation-duration": 21, + "animation-timing-function": 22, + "animation-delay": 23, + "animation-iteration-count": 24, + "animation-direction": 25, + "animation-fill-mode": 26, + "animation-play-state": 27, + + // Transition properties (indices 30-33) + "transition-property": 30, + "transition-duration": 31, + "transition-timing-function": 32, + "transition-delay": 33, + + // Background properties (indices 40-49) + "background-image": 40, + "background-position": 41, + "background-position-x": 42, + "background-position-y": 43, + "background-size": 44, + "background-repeat": 45, + "background-attachment": 46, + "background-origin": 47, + "background-clip": 48, + "background-color": 49, + + // Font properties (indices 50-56) + "font-style": 50, + "font-variant": 51, + "font-weight": 52, + "font-stretch": 53, + "font-size": 54, + "line-height": 55, + "font-family": 56, + + // Flex properties (indices 60-64) + "flex-grow": 60, + "flex-shrink": 61, + "flex-basis": 62, + "flex-direction": 63, + "flex-wrap": 64, + + // Border directional properties (indices 70-84) + "border-top-width": 70, + "border-top-style": 71, + "border-top-color": 72, + "border-right-width": 73, + "border-right-style": 74, + "border-right-color": 75, + "border-bottom-width": 76, + "border-bottom-style": 77, + "border-bottom-color": 78, + "border-left-width": 79, + "border-left-style": 80, + "border-left-color": 81, + "border-width": 82, + "border-style": 83, + "border-color": 84, + + // Border-radius properties (indices 90-93) + "border-top-left-radius": 90, + "border-top-right-radius": 91, + "border-bottom-right-radius": 92, + "border-bottom-left-radius": 93, + + // Outline properties (indices 100-102) + "outline-width": 100, + "outline-style": 101, + "outline-color": 102, + + // Column-rule properties (indices 110-112) + "column-rule-width": 110, + "column-rule-style": 111, + "column-rule-color": 112, + + // Columns properties (indices 115-116) + "column-width": 115, + "column-count": 116, + + // List-style properties (indices 120-122) + "list-style-type": 120, + "list-style-position": 121, + "list-style-image": 122, + + // Text-decoration properties (indices 130-133) + "text-decoration-line": 130, + "text-decoration-style": 131, + "text-decoration-color": 132, + "text-decoration-thickness": 133, + + // Overflow properties (indices 140-141) + "overflow-x": 140, + "overflow-y": 141, + + // Place properties (indices 150-155) + "align-items": 150, + "justify-items": 151, + "align-content": 152, + "justify-content": 153, + "align-self": 154, + "justify-self": 155, + + // Directional properties - margin (indices 200-203) + "margin-top": 200, + "margin-right": 201, + "margin-bottom": 202, + "margin-left": 203, + + // Directional properties - padding (indices 210-213) + "padding-top": 210, + "padding-right": 211, + "padding-bottom": 212, + "padding-left": 213, + + // Directional properties - inset (indices 220-223) + top: 220, + right: 221, + bottom: 222, + left: 223, + + // Mask properties (indices 230-239) + "mask-image": 230, + "mask-mode": 231, + "mask-position": 232, + "mask-position-x": 233, + "mask-position-y": 234, + "mask-size": 235, + "mask-repeat": 236, + "mask-origin": 237, + "mask-clip": 238, + "mask-composite": 239, + + // Object properties (indices 245-246) + "object-position-x": 245, + "object-position-y": 246, + + // Offset properties (indices 240-244) + "offset-position": 240, + "offset-path": 241, + "offset-distance": 242, + "offset-rotate": 243, + "offset-anchor": 244, + + // Text-emphasis properties (indices 250-252) + "text-emphasis-style": 250, + "text-emphasis-color": 251, + "text-emphasis-position": 252, + + // Contain-intrinsic-size properties (indices 260-261) + "contain-intrinsic-width": 260, + "contain-intrinsic-height": 261, +}; + +/** + * Sorts an object's properties according to CSS specification order. + * @internal + */ +export function sortProperties( + obj: Record, + grouping: PropertyGrouping = GROUPING_BY_PROPERTY +): Record { + if (grouping === "by-side") { + return sortPropertiesBySide(obj); + } + return sortPropertiesByProperty(obj); +} + +/** + * Helper to extract property metadata for directional grouping. + * @internal + */ +function getPropertyMetadata(prop: string): { + side: string | null; + sideIndex: number; + base: string; +} { + const parts = prop.split("-"); + const sides = ["top", "right", "bottom", "left"]; + const side = parts.find((p) => sides.includes(p)) || null; + const sideIndex = side ? sides.indexOf(side) : -1; + const base = parts[0]; + + return { side, sideIndex, base }; +} + +/** + * Sort properties by property type (default CSS spec order). + * @internal + */ +function sortPropertiesByProperty(obj: Record): Record { + const sortedEntries = Object.entries(obj).sort(([a], [b]) => { + const orderA = PROPERTY_ORDER_MAP[a]; + const orderB = PROPERTY_ORDER_MAP[b]; + + if (orderA !== undefined && orderB !== undefined) { + return orderA - orderB; + } + if (orderA !== undefined) return -1; + if (orderB !== undefined) return 1; + return a.localeCompare(b); + }); + + return Object.fromEntries(sortedEntries); +} + +/** + * Sort properties by directional side. + * @internal + */ +function sortPropertiesBySide(obj: Record): Record { + const sortedEntries = Object.entries(obj).sort(([a], [b]) => { + const metaA = getPropertyMetadata(a); + const metaB = getPropertyMetadata(b); + + if (metaA.side && metaB.side) { + if (metaA.sideIndex !== metaB.sideIndex) { + return metaA.sideIndex - metaB.sideIndex; + } + const orderA = PROPERTY_ORDER_MAP[a] ?? 999999; + const orderB = PROPERTY_ORDER_MAP[b] ?? 999999; + return orderA - orderB; + } + + if (!metaA.side && metaB.side) { + const orderA = PROPERTY_ORDER_MAP[a] ?? 999999; + if (orderA < 200) return -1; + return 1; + } + if (metaA.side && !metaB.side) { + const orderB = PROPERTY_ORDER_MAP[b] ?? 999999; + if (orderB < 200) return 1; + return -1; + } + + const orderA = PROPERTY_ORDER_MAP[a] ?? 999999; + const orderB = PROPERTY_ORDER_MAP[b] ?? 999999; + if (orderA !== orderB) return orderA - orderB; + return a.localeCompare(b); + }); + + return Object.fromEntries(sortedEntries); +} + +/** + * Converts a kebab-case CSS property name to camelCase for JavaScript. + * @internal + */ +export function kebabToCamelCase(property: string): string { + return property.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +/** + * Converts a CSS object to CSS string format. + * @internal + */ +export function objectToCss( + obj: Record, + indent: number, + separator: string, + propertyGrouping: PropertyGrouping = GROUPING_BY_PROPERTY +): string { + const indentStr = " ".repeat(indent); + const sorted = sortProperties(obj, propertyGrouping); + const sortedEntries = Object.entries(sorted); + return sortedEntries.map(([key, value]) => `${indentStr}${key}: ${value};`).join(separator); +} + + +=== File: src/internal/shorthand-registry.ts === +// b_path:: src/internal/shorthand-registry.ts + +import animation from "../handlers/animation"; +import background from "../handlers/background"; +import { backgroundPositionHandler } from "../handlers/background-position"; +import border from "../handlers/border"; +import borderRadius from "../handlers/border-radius"; +import columnRule from "../handlers/column-rule"; +import columns from "../handlers/columns"; +import containIntrinsicSize from "../handlers/contain-intrinsic-size"; +import flex from "../handlers/flex"; +import flexFlow from "../handlers/flex-flow"; +import font from "../handlers/font"; +import grid from "../handlers/grid"; +import gridArea from "../handlers/grid-area"; +import gridColumn from "../handlers/grid-column"; +import gridRow from "../handlers/grid-row"; +import listStyle from "../handlers/list-style"; +import mask from "../handlers/mask"; +import offset from "../handlers/offset"; +import outline from "../handlers/outline"; +import overflow from "../handlers/overflow"; +import placeContent from "../handlers/place-content"; +import placeItems from "../handlers/place-items"; +import placeSelf from "../handlers/place-self"; +import textDecoration from "../handlers/text-decoration"; +import textEmphasis from "../handlers/text-emphasis"; +import transition from "../handlers/transition"; +import directional from "./directional"; + +/** + * Internal shorthand property handler registry. + * @internal + */ + +const prefix = + (prefix: string) => + (value: string): Record | undefined => { + const longhand = directional(value); + + if (!longhand) return; + + const result: Record = {}; + for (const key in longhand) { + result[`${prefix}-${key}`] = longhand[key]; + } + return result; + }; + +/** + * Central registry mapping shorthand property names to their expansion handlers. + * @internal + */ +export const shorthand: Record Record | undefined> = { + animation: animation, + background: background, + "background-position": (value: string) => backgroundPositionHandler.expand(value), + border: border, + "border-bottom": border.bottom, + "border-color": border.color, + "border-left": border.left, + "border-radius": borderRadius, + "border-right": border.right, + "border-style": border.style, + "border-top": border.top, + "border-width": border.width, + columns: columns, + "column-rule": columnRule, + "contain-intrinsic-size": containIntrinsicSize, + flex: flex, + "flex-flow": flexFlow, + font: font, + grid: grid, + "grid-area": gridArea, + "grid-column": gridColumn, + "grid-row": gridRow, + inset: directional, + "list-style": listStyle, + mask: mask, + margin: prefix("margin"), + offset: offset, + outline: outline, + overflow: overflow, + padding: prefix("padding"), + "place-content": placeContent, + "place-items": placeItems, + "place-self": placeSelf, + "text-decoration": textDecoration, + "text-emphasis": textEmphasis, + transition: transition, +}; + + +=== File: src/internal/trbl-expander.ts === +// b_path:: src/internal/trbl-expander.ts + +/** + * TRBL (Top-Right-Bottom-Left) expander utility for box model shorthands + * + * Handles: margin, padding, inset, scroll-margin, scroll-padding, border-width, border-style, border-color + * + * CSS grammar: + * - 1 value: all sides + * - 2 values: vertical horizontal + * - 3 values: top horizontal bottom + * - 4 values: top right bottom left + */ + +export interface TRBLResult { + top: string; + right: string; + bottom: string; + left: string; +} + +/** + * Smart split that preserves function calls like calc(), var() + */ +function smartSplit(value: string): string[] { + const result: string[] = []; + let current = ""; + let depth = 0; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + + if (char === "(") { + depth++; + current += char; + } else if (char === ")") { + depth--; + current += char; + } else if (char === " " && depth === 0) { + if (current.trim()) { + result.push(current.trim()); + current = ""; + } + } else { + current += char; + } + } + + if (current.trim()) { + result.push(current.trim()); + } + + return result; +} + +/** + * Expand a TRBL shorthand value into individual side values + * + * @param value - The CSS value (e.g., "10px 20px", "1em") + * @returns Object with top, right, bottom, left values + * + * @example + * expandTRBL("10px") // { top: "10px", right: "10px", bottom: "10px", left: "10px" } + * expandTRBL("10px 20px") // { top: "10px", right: "20px", bottom: "10px", left: "20px" } + * expandTRBL("10px 20px 30px") // { top: "10px", right: "20px", bottom: "30px", left: "20px" } + * expandTRBL("10px 20px 30px 40px") // { top: "10px", right: "20px", bottom: "30px", left: "40px" } + */ +export function expandTRBL(value: string): TRBLResult { + const trimmed = value.trim(); + + // Handle global values + if ( + trimmed === "initial" || + trimmed === "inherit" || + trimmed === "unset" || + trimmed === "revert" + ) { + return { top: trimmed, right: trimmed, bottom: trimmed, left: trimmed }; + } + + const parts = smartSplit(trimmed); + + if (parts.length === 0) { + return { top: "0", right: "0", bottom: "0", left: "0" }; + } + + // 1 value: all sides + if (parts.length === 1) { + const val = parts[0]; + return { top: val, right: val, bottom: val, left: val }; + } + + // 2 values: vertical horizontal + if (parts.length === 2) { + const [vertical, horizontal] = parts; + return { top: vertical, right: horizontal, bottom: vertical, left: horizontal }; + } + + // 3 values: top horizontal bottom + if (parts.length === 3) { + const [top, horizontal, bottom] = parts; + return { top, right: horizontal, bottom, left: horizontal }; + } + + // 4 values: top right bottom left + const [top, right, bottom, left] = parts; + return { top, right, bottom, left }; +} + +/** + * Create a TRBL property expander for a given property prefix + * + * @param prefix - The property prefix (e.g., "margin", "padding", "border-width") + * @returns Function that expands the shorthand to longhand properties + * + * @example + * const expandMargin = createTRBLExpander("margin"); + * expandMargin("10px 20px") // { "margin-top": "10px", "margin-right": "20px", ... } + */ +export function createTRBLExpander(prefix: string) { + return (value: string): Record => { + const { top, right, bottom, left } = expandTRBL(value); + return { + [`${prefix}-top`]: top, + [`${prefix}-right`]: right, + [`${prefix}-bottom`]: bottom, + [`${prefix}-left`]: left, + }; + }; +} + + diff --git a/src/core/expand.ts b/src/core/expand.ts index 170ce45..02acc0a 100644 --- a/src/core/expand.ts +++ b/src/core/expand.ts @@ -1,6 +1,5 @@ // b_path:: src/core/expand.ts -import { hasTopLevelCommas } from "../internal/layer-parser-utils"; import { parseCssDeclaration, parseInputString, stripComments } from "../internal/parsers"; import { kebabToCamelCase, objectToCss, sortProperties } from "../internal/property-sorter"; import { shorthand } from "../internal/shorthand-registry"; @@ -77,9 +76,8 @@ export function expand(input: string, options: Partial = {}): Exp // as they need layer-aware splitting first for (const [prop, val] of Object.entries(longhand)) { const nestedParse = shorthand[prop]; - const isMultiLayer = hasTopLevelCommas(val); - if (nestedParse && !isMultiLayer) { + if (nestedParse) { const nestedLonghand = nestedParse(val); if (nestedLonghand) { Object.assign(finalProperties, nestedLonghand); diff --git a/src/handlers/background-position/expand.ts b/src/handlers/background-position/expand.ts index 51f3c4a..bad3a92 100644 --- a/src/handlers/background-position/expand.ts +++ b/src/handlers/background-position/expand.ts @@ -1,17 +1,42 @@ // b_path:: src/handlers/background-position/expand.ts +import { splitLayers } from "../../internal/layer-parser-utils"; import { parsePosition } from "../../internal/position-parser"; /** * Expand background-position shorthand to longhand properties * * background-position → background-position-x, background-position-y + * + * Handles both single and multi-layer values: + * - "center" → { x: "center", y: "center" } + * - "center, left top" → { x: "center, left", y: "center, top" } */ export function expandBackgroundPosition(value: string): Record { - const { x, y } = parsePosition(value); + // Check if we have multiple layers (comma-separated) + const layers = splitLayers(value); + + if (layers.length === 1) { + // Single layer - simple case + const { x, y } = parsePosition(value); + return { + "background-position-x": x, + "background-position-y": y, + }; + } + + // Multi-layer - expand each layer and rejoin + const xValues: string[] = []; + const yValues: string[] = []; + + for (const layer of layers) { + const { x, y } = parsePosition(layer); + xValues.push(x); + yValues.push(y); + } return { - "background-position-x": x, - "background-position-y": y, + "background-position-x": xValues.join(", "), + "background-position-y": yValues.join(", "), }; } diff --git a/test/invalid-cases.test.ts b/test/invalid-cases.test.ts index cbdb5f4..57c5878 100644 --- a/test/invalid-cases.test.ts +++ b/test/invalid-cases.test.ts @@ -376,7 +376,8 @@ describe("background (invalid cases)", () => { assertNoDuplicateProperties(result, "should parse multi-layer background with extra token"); expect(result).toEqual({ backgroundImage: "url(image1.png), url(image2.png)", - backgroundPosition: "0% 0%, 0% 0%", + backgroundPositionX: "0%, 0%", + backgroundPositionY: "0%, 0%", backgroundSize: "auto auto, auto auto", backgroundRepeat: "repeat, repeat", backgroundAttachment: "scroll, scroll", diff --git a/test/recursive-expansion.test.ts b/test/recursive-expansion.test.ts index 24bd2b6..1beba49 100644 --- a/test/recursive-expansion.test.ts +++ b/test/recursive-expansion.test.ts @@ -27,12 +27,11 @@ describe("Recursive shorthand expansion", () => { const result = expand(css, { format: "css" }); expect(result.ok).toBe(true); - // Multi-layer background-position values are NOT recursively expanded - // They stay as background-position (composite longhand) because layer-aware - // splitting is needed first before per-layer expansion - expect(result.result).toContain("background-position:"); - expect(result.result).not.toContain("background-position-x"); - expect(result.result).not.toContain("background-position-y"); + // Multi-layer background-position values ARE now recursively expanded + // Each layer's position is split into -x and -y, then rejoined with commas + expect(result.result).not.toContain("background-position:"); + expect(result.result).toContain("background-position-x"); + expect(result.result).toContain("background-position-y"); }); it("should expand background-position when used directly", () => {