diff --git a/.env.dist b/.env.dist index 77dfe57f3..ddc848c2e 100755 --- a/.env.dist +++ b/.env.dist @@ -4,6 +4,8 @@ APP_KEY= APP_DEBUG=true APP_URL=http://localhost +# RSX Application Mode: development, debug, or production + LOG_CHANNEL=stack LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=info @@ -54,3 +56,4 @@ GATEKEEPER_SUBTITLE="This is a restricted development preview site. Please enter SSR_FPC_ENABLED=true LOG_BROWSER_ERRORS=false AJAX_DISABLE_BATCHING=false +RSX_MODE=development diff --git a/.gitignore b/.gitignore index 8a0fa2b40..ae5331508 100755 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,11 @@ yarn-error.log ._* Thumbs.db -# Entire storage directory - will be created on bootstrap -/storage/ +# Storage directory (except cdn-cache which is committed) +/storage/* +!/storage/rsx-build/ +/storage/rsx-build/* +!/storage/rsx-build/cdn-cache/ # Supervisor files supervisord.log* diff --git a/app/RSpade/Commands/Migrate/Migrate_Normalize_Schema_Command.php b/app/RSpade/Commands/Migrate/Migrate_Normalize_Schema_Command.php index 7ab860fb0..97eb024ab 100644 --- a/app/RSpade/Commands/Migrate/Migrate_Normalize_Schema_Command.php +++ b/app/RSpade/Commands/Migrate/Migrate_Normalize_Schema_Command.php @@ -261,6 +261,12 @@ class Migrate_Normalize_Schema_Command extends Command if (in_array($tableName, $ajaxableTables) && !Schema::hasColumn($tableName, 'version')) { DB::statement("ALTER TABLE $tableName ADD COLUMN version INT(11) NOT NULL DEFAULT 1"); } + + // Order column normalization + // Tables with `order` column get: BIGINT DEFAULT NULL, order_idx index, auto-increment triggers + if (Schema::hasColumn($tableName, 'order')) { + $this->normalizeOrderColumn($tableName); + } } // Convert table to utf8mb4 if needed @@ -407,4 +413,100 @@ class Migrate_Normalize_Schema_Command extends Command return count($indexes) > 0; } + + /** + * Check if a trigger exists + * + * @param string $triggerName The name of the trigger + * @return bool True if the trigger exists + */ + private function triggerExists($triggerName) + { + $triggers = DB::select("SHOW TRIGGERS WHERE `Trigger` = ?", [$triggerName]); + + return count($triggers) > 0; + } + + /** + * Normalize order column for a table + * + * Ensures: + * - Column is BIGINT DEFAULT NULL + * - Index order_idx exists on (order) + * - Triggers exist for auto-incrementing NULL values on INSERT/UPDATE + * + * @param string $tableName The name of the table + */ + private function normalizeOrderColumn($tableName) + { + // 1. Ensure column type is BIGINT DEFAULT NULL + $column_info = DB::selectOne( + "SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = 'order'", + [$tableName] + ); + + if ($column_info) { + $needs_modify = false; + + // Check if type is BIGINT (case-insensitive, may include display width) + if (stripos($column_info->COLUMN_TYPE, 'bigint') === false) { + $needs_modify = true; + } + + // Check if nullable + if ($column_info->IS_NULLABLE !== 'YES') { + $needs_modify = true; + } + + // Check if default is NULL + if ($column_info->COLUMN_DEFAULT !== null) { + $needs_modify = true; + } + + if ($needs_modify) { + DB::statement("ALTER TABLE `$tableName` MODIFY COLUMN `order` BIGINT DEFAULT NULL"); + } + } + + // 2. Ensure order_idx index exists + if (!$this->indexExists($tableName, 'order_idx')) { + DB::statement("ALTER TABLE `$tableName` ADD INDEX order_idx(`order`)"); + } + + // 3. Ensure triggers exist for auto-incrementing NULL values + $insert_trigger_name = "{$tableName}_order_insert"; + $update_trigger_name = "{$tableName}_order_update"; + + // Create INSERT trigger if not exists + if (!$this->triggerExists($insert_trigger_name)) { + DB::unprepared(" + CREATE TRIGGER `{$insert_trigger_name}` + BEFORE INSERT ON `{$tableName}` + FOR EACH ROW + BEGIN + IF NEW.`order` IS NULL THEN + SET NEW.`order` = (SELECT COALESCE(MAX(`order`), 0) + 1 FROM `{$tableName}`); + END IF; + END + "); + } + + // Create UPDATE trigger if not exists + if (!$this->triggerExists($update_trigger_name)) { + DB::unprepared(" + CREATE TRIGGER `{$update_trigger_name}` + BEFORE UPDATE ON `{$tableName}` + FOR EACH ROW + BEGIN + IF NEW.`order` IS NULL THEN + SET NEW.`order` = (SELECT COALESCE(MAX(`order`), 0) + 1 FROM `{$tableName}`); + END IF; + END + "); + } + } } diff --git a/app/RSpade/Commands/Rsx/Mode_Set_Command.php b/app/RSpade/Commands/Rsx/Mode_Set_Command.php new file mode 100755 index 000000000..304279446 --- /dev/null +++ b/app/RSpade/Commands/Rsx/Mode_Set_Command.php @@ -0,0 +1,241 @@ +argument('mode'); + + // Normalize mode aliases + $normalized = match (strtolower($mode)) { + 'dev', 'development' => Rsx_Mode::DEVELOPMENT, + 'debug' => Rsx_Mode::DEBUG, + 'prod', 'production' => Rsx_Mode::PRODUCTION, + default => null, + }; + + if ($normalized === null) { + $this->error("Invalid mode: {$mode}"); + $this->line(''); + $this->line('Valid modes:'); + $this->line(' dev, development - Auto-rebuild, full debugging'); + $this->line(' debug - Production optimizations with sourcemaps'); + $this->line(' prod, production - Full optimization'); + + return 1; + } + + // Check current mode (for display purposes only - always rebuild) + $current_mode = $this->_get_current_env_mode(); + if ($current_mode === $normalized) { + $this->info("Rebuilding {$normalized} mode..."); + } else { + $this->info("Switching to {$normalized} mode..."); + } + $this->newLine(); + + // Step 1: Update .env file + $this->line(' [1/3] Updating .env...'); + $this->_update_env_mode($normalized); + + // Clear the cached mode so subsequent calls see the new value + Rsx_Mode::clear_cache(); + + // Step 2: Clear all caches + $this->line(' [2/3] Clearing caches...'); + + // Clear Laravel caches by deleting files directly (avoids triggering Manifest::init) + $this->_clear_laravel_caches(); + + // Clear RSX caches directly (avoids triggering Manifest::init via artisan boot) + $this->_clear_rsx_caches(); + + // Step 3: Build appropriate assets + $this->line(' [3/3] Building assets...'); + + if ($normalized === Rsx_Mode::DEVELOPMENT) { + // In development, pre-warm the bundle cache + // Explicitly pass RSX_MODE to ensure subprocess uses correct mode + passthru("RSX_MODE={$normalized} php artisan rsx:bundle:compile", $exit_code); + if ($exit_code !== 0) { + $this->warn('Bundle compilation had warnings, but continuing...'); + } + } else { + // In debug/production, run the production build + // RSX_FORCE_BUILD allows manifest rebuild even in production mode + // Explicitly pass RSX_MODE to ensure subprocess uses correct mode + passthru("RSX_MODE={$normalized} RSX_FORCE_BUILD=1 php artisan rsx:prod:build", $exit_code); + if ($exit_code !== 0) { + $this->error('Production build failed'); + + return 1; + } + } + + $this->newLine(); + $this->info("[OK] Switched to {$normalized} mode"); + + return 0; + } + + /** + * Get current RSX_MODE from .env file (not from env() which may be cached) + */ + private function _get_current_env_mode(): string + { + $env_path = base_path('.env'); + if (!file_exists($env_path)) { + return Rsx_Mode::DEVELOPMENT; + } + + $contents = file_get_contents($env_path); + if (preg_match('/^RSX_MODE=(.*)$/m', $contents, $matches)) { + return trim($matches[1]); + } + + return Rsx_Mode::DEVELOPMENT; + } + + /** + * Update RSX_MODE in .env file + */ + private function _update_env_mode(string $mode): void + { + $env_path = base_path('.env'); + + if (!file_exists($env_path)) { + // Create .env with just RSX_MODE + file_put_contents($env_path, "RSX_MODE={$mode}\n"); + + return; + } + + $contents = file_get_contents($env_path); + + if (preg_match('/^RSX_MODE=.*$/m', $contents)) { + // Replace existing RSX_MODE + $contents = preg_replace('/^RSX_MODE=.*$/m', "RSX_MODE={$mode}", $contents); + } else { + // Add RSX_MODE after APP_DEBUG or APP_URL if they exist, otherwise at end + if (preg_match('/^APP_DEBUG=.*$/m', $contents)) { + $contents = preg_replace( + '/^(APP_DEBUG=.*)$/m', + "$1\nRSX_MODE={$mode}", + $contents, + 1 + ); + } elseif (preg_match('/^APP_URL=.*$/m', $contents)) { + $contents = preg_replace( + '/^(APP_URL=.*)$/m', + "$1\nRSX_MODE={$mode}", + $contents, + 1 + ); + } else { + // Append to end + $contents = rtrim($contents) . "\nRSX_MODE={$mode}\n"; + } + } + + file_put_contents($env_path, $contents); + } + + /** + * Clear Laravel caches by deleting files directly + * (Avoids running artisan commands which trigger Manifest::init) + */ + private function _clear_laravel_caches(): void + { + $bootstrap_cache = base_path('bootstrap/cache'); + + // Config cache + $config_cache = "{$bootstrap_cache}/config.php"; + if (file_exists($config_cache)) { + unlink($config_cache); + } + + // Route cache + $route_cache = "{$bootstrap_cache}/routes-v7.php"; + if (file_exists($route_cache)) { + unlink($route_cache); + } + + // Services cache + $services_cache = "{$bootstrap_cache}/services.php"; + if (file_exists($services_cache)) { + unlink($services_cache); + } + + // Packages cache + $packages_cache = "{$bootstrap_cache}/packages.php"; + if (file_exists($packages_cache)) { + unlink($packages_cache); + } + + // Compiled views + $views_path = storage_path('framework/views'); + if (is_dir($views_path)) { + $files = glob("{$views_path}/*.php"); + foreach ($files as $file) { + unlink($file); + } + } + } + + /** + * Clear RSX caches by deleting directories directly + * (Avoids running artisan commands which trigger Manifest::init) + */ + private function _clear_rsx_caches(): void + { + // Clear rsx-build directory + $build_path = storage_path('rsx-build'); + if (is_dir($build_path)) { + $this->_clear_directory_contents($build_path); + } + + // Clear rsx-tmp directory + $tmp_path = storage_path('rsx-tmp'); + if (is_dir($tmp_path)) { + $this->_clear_directory_contents($tmp_path); + } + } + + /** + * Recursively clear directory contents + */ + private function _clear_directory_contents(string $path): void + { + if (!is_dir($path)) { + return; + } + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getRealPath()); + } else { + unlink($file->getRealPath()); + } + } + } +} diff --git a/app/RSpade/Commands/Rsx/Prod_Build_Command.php b/app/RSpade/Commands/Rsx/Prod_Build_Command.php new file mode 100755 index 000000000..1860eea08 --- /dev/null +++ b/app/RSpade/Commands/Rsx/Prod_Build_Command.php @@ -0,0 +1,180 @@ +warn('Warning: Building production assets in development mode.'); + $this->line('Assets will be built but will not be used until you switch modes:'); + $this->line(' php artisan rsx:mode:set debug'); + $this->line(' php artisan rsx:mode:set production'); + $this->newLine(); + } + + $this->info('Building production assets...'); + $this->newLine(); + + $start_time = microtime(true); + + // Step 1: Rebuild manifest + $this->line('[1/3] Building manifest...'); + try { + // Enable force build mode to allow rebuilding in production-like modes + Manifest::$_force_build = true; + + // Force a fresh manifest rebuild by clearing and re-initializing + Manifest::clear(); + Manifest::init(); + $this->line(' Manifest built successfully'); + } catch (Exception $e) { + $this->error(' Failed to build manifest: ' . $e->getMessage()); + + return 1; + } finally { + Manifest::$_force_build = false; + } + + // Step 2: Compile all bundles + $this->line('[2/3] Compiling bundles...'); + + // Force restart minify server to pick up any code changes + if (Rsx_Mode::is_production()) { + Minifier::force_restart(); + } + + $manifest_data = Manifest::get_all(); + $bundle_classes = []; + + foreach ($manifest_data as $file_info) { + $class_name = $file_info['class'] ?? null; + if ($class_name && Manifest::php_is_subclass_of($class_name, 'Rsx_Module_Bundle_Abstract')) { + $fqcn = $file_info['fqcn'] ?? $class_name; + $bundle_classes[$fqcn] = $class_name; + } + } + + if (empty($bundle_classes)) { + $this->warn(' No bundles found in manifest'); + } else { + // Ensure storage directory exists + $bundle_dir = storage_path('rsx-build/bundles'); + if (!is_dir($bundle_dir)) { + mkdir($bundle_dir, 0755, true); + } + + $compiled_count = 0; + $failed_bundles = []; + + foreach ($bundle_classes as $fqcn => $class_name) { + $this->line(" Compiling: {$class_name}"); + + try { + $compiler = new BundleCompiler(); + $compiled = $compiler->compile($fqcn, ['force_build' => true]); + + // Get output file info (always vendor/app split) + $js_size = 0; + $css_size = 0; + + if (isset($compiled['vendor_js_bundle_path'])) { + $js_size += filesize("{$bundle_dir}/{$compiled['vendor_js_bundle_path']}"); + } + if (isset($compiled['app_js_bundle_path'])) { + $js_size += filesize("{$bundle_dir}/{$compiled['app_js_bundle_path']}"); + } + if (isset($compiled['vendor_css_bundle_path'])) { + $css_size += filesize("{$bundle_dir}/{$compiled['vendor_css_bundle_path']}"); + } + if (isset($compiled['app_css_bundle_path'])) { + $css_size += filesize("{$bundle_dir}/{$compiled['app_css_bundle_path']}"); + } + + $this->line(' JS: ' . $this->_format_size($js_size) . ', CSS: ' . $this->_format_size($css_size)); + $compiled_count++; + } catch (Exception $e) { + $this->error(" Failed: {$e->getMessage()}"); + $failed_bundles[$class_name] = $e->getMessage(); + } + } + + if (!empty($failed_bundles)) { + $this->error(" {$compiled_count} compiled, " . count($failed_bundles) . ' failed'); + + return 1; + } + + $this->line(" {$compiled_count} bundles compiled"); + } + + // Step 3: Laravel caches + if (!$this->option('skip-laravel-cache')) { + $this->line('[3/3] Building Laravel caches...'); + + passthru('php artisan config:cache 2>/dev/null', $exit_code); + if ($exit_code === 0) { + $this->line(' Config cached'); + } + + passthru('php artisan route:cache 2>/dev/null', $exit_code); + if ($exit_code === 0) { + $this->line(' Routes cached'); + } + + passthru('php artisan view:cache 2>/dev/null', $exit_code); + if ($exit_code === 0) { + $this->line(' Views cached'); + } + } else { + $this->line('[3/3] Skipping Laravel caches (--skip-laravel-cache)'); + } + + $elapsed = round(microtime(true) - $start_time, 2); + + $this->newLine(); + $this->info("[OK] Production build complete ({$elapsed}s)"); + + if ($mode === Rsx_Mode::DEVELOPMENT) { + $this->newLine(); + $this->line('To use these assets, switch to production mode:'); + $this->line(' php artisan rsx:mode:set production'); + } + + return 0; + } + + private function _format_size(int $bytes): string + { + if ($bytes < 1024) { + return "{$bytes} B"; + } + if ($bytes < 1048576) { + return round($bytes / 1024, 1) . ' KB'; + } + + return round($bytes / 1048576, 2) . ' MB'; + } +} diff --git a/app/RSpade/Commands/Rsx/Prod_Export_Command.php b/app/RSpade/Commands/Rsx/Prod_Export_Command.php new file mode 100755 index 000000000..56c49339a --- /dev/null +++ b/app/RSpade/Commands/Rsx/Prod_Export_Command.php @@ -0,0 +1,231 @@ +option('path'); + + // Resolve relative path from base_path parent (since we're in /system) + if (str_starts_with($export_path, './')) { + $export_path = dirname(base_path()) . '/' . substr($export_path, 2); + } elseif (!str_starts_with($export_path, '/')) { + $export_path = dirname(base_path()) . '/' . $export_path; + } + + $this->info('Exporting application for deployment...'); + $this->line(" Destination: {$export_path}"); + $this->newLine(); + + // Step 1: Run production build (unless --skip-build) + if (!$this->option('skip-build')) { + $this->line('[1/3] Running production build...'); + passthru('php artisan rsx:prod:build', $exit_code); + if ($exit_code !== 0) { + $this->error('Production build failed'); + + return 1; + } + $this->newLine(); + } else { + $this->line('[1/3] Skipping production build (--skip-build)'); + } + + // Step 2: Prepare export directory + $this->line('[2/3] Preparing export directory...'); + + // Clear existing export if it exists + if (is_dir($export_path)) { + $this->line(' Clearing existing export...'); + $this->_clear_directory($export_path); + } + + // Create export directory + if (!mkdir($export_path, 0755, true) && !is_dir($export_path)) { + $this->error("Failed to create export directory: {$export_path}"); + + return 1; + } + + // Step 3: Copy files + $this->line('[3/3] Copying files...'); + + $base = dirname(base_path()); // Parent of /system + $copied_files = 0; + $copied_dirs = 0; + + // Directories to copy + $dirs_to_copy = [ + 'system' => 'system', + 'rsx' => 'rsx', + 'node_modules' => 'node_modules', + 'vendor' => 'vendor', + ]; + + // Also include rsx-build from storage + $storage_rsx_build = base_path('storage/rsx-build'); + if (is_dir($storage_rsx_build)) { + $dirs_to_copy['system/storage/rsx-build'] = 'system/storage/rsx-build'; + } + + foreach ($dirs_to_copy as $src_rel => $dest_rel) { + $src = "{$base}/{$src_rel}"; + $dest = "{$export_path}/{$dest_rel}"; + + if (!is_dir($src)) { + $this->warn(" Skipping {$src_rel} (not found)"); + continue; + } + + $this->line(" Copying {$src_rel}/..."); + $count = $this->_copy_directory($src, $dest, $src_rel); + $copied_files += $count; + $copied_dirs++; + } + + // Copy *.json files from root + $json_files = glob("{$base}/*.json"); + foreach ($json_files as $json_file) { + $filename = basename($json_file); + copy($json_file, "{$export_path}/{$filename}"); + $copied_files++; + } + if (!empty($json_files)) { + $this->line(' Copying *.json files...'); + } + + // Create .gitignore for export directory + file_put_contents( + "{$export_path}/.gitignore", + "# This export should not be committed to version control\n*\n" + ); + + $this->newLine(); + $this->info("[OK] Export complete"); + $this->line(" {$copied_dirs} directories, {$copied_files} files"); + $this->line(" Location: {$export_path}"); + $this->newLine(); + $this->line('Next steps:'); + $this->line(' 1. Copy the export directory to your production server'); + $this->line(' 2. Configure .env on the production server'); + $this->line(' 3. Point your web server to the deployment'); + + return 0; + } + + /** + * Copy a directory recursively, excluding certain paths + */ + private function _copy_directory(string $src, string $dest, string $rel_path): int + { + // Paths to exclude (relative to source) + $exclude_patterns = [ + '.env', + '.env.example', + 'storage/app', + 'storage/logs', + 'storage/framework/cache', + 'storage/framework/sessions', + 'storage/framework/views', + 'storage/rsx-tmp', + 'rsx-export', + '.git', + '.idea', + '.vscode', + 'tests', + ]; + + // For system directory, exclude additional paths + if ($rel_path === 'system') { + $exclude_patterns[] = 'storage/rsx-tmp'; + } + + if (!is_dir($dest)) { + mkdir($dest, 0755, true); + } + + $copied = 0; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($src, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + $sub_path = $iterator->getSubPathname(); + + // Check exclusions + $skip = false; + foreach ($exclude_patterns as $pattern) { + if (str_starts_with($sub_path, $pattern) || str_contains($sub_path, "/{$pattern}")) { + $skip = true; + break; + } + } + + if ($skip) { + continue; + } + + $dest_path = "{$dest}/{$sub_path}"; + + if ($item->isDir()) { + if (!is_dir($dest_path)) { + mkdir($dest_path, 0755, true); + } + } else { + // Ensure parent directory exists + $parent = dirname($dest_path); + if (!is_dir($parent)) { + mkdir($parent, 0755, true); + } + copy($item->getPathname(), $dest_path); + $copied++; + } + } + + return $copied; + } + + /** + * Clear a directory recursively + */ + private function _clear_directory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($path); + } +} diff --git a/app/RSpade/Core/Bundle/BundleCompiler.php b/app/RSpade/Core/Bundle/BundleCompiler.php index abff36683..83e8281bf 100644 --- a/app/RSpade/Core/Bundle/BundleCompiler.php +++ b/app/RSpade/Core/Bundle/BundleCompiler.php @@ -7,10 +7,13 @@ use RecursiveCallbackFilterIterator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use RuntimeException; +use App\RSpade\Core\Bundle\Cdn_Cache; +use App\RSpade\Core\Bundle\Minifier; use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract; use App\RSpade\Core\Bundle\Rsx_Module_Bundle_Abstract; use App\RSpade\Core\Locks\RsxLocks; use App\RSpade\Core\Manifest\Manifest; +use App\RSpade\Core\Mode\Rsx_Mode; /** * BundleCompiler - Compiles RSX bundles into JS and CSS files @@ -121,18 +124,25 @@ class BundleCompiler public function compile(string $bundle_class, array $options = []): array { $this->bundle_name = $this->_get_bundle_name($bundle_class); - $this->is_production = app()->environment('production'); + $this->is_production = Rsx_Mode::is_production_like(); + $force_build = $options['force_build'] ?? false; - console_debug('BUNDLE', "Compiling {$this->bundle_name} (production: " . ($this->is_production ? 'yes' : 'no') . ')'); + console_debug('BUNDLE', "Compiling {$this->bundle_name} (mode: " . Rsx_Mode::get() . ')'); - // Step 1: Check production cache - if ($this->is_production) { + // Step 1: In production-like modes, require pre-built bundles (unless force_build) + if ($this->is_production && !$force_build) { $existing = $this->_check_production_cache(); if ($existing) { console_debug('BUNDLE', 'Using existing production bundle'); return $existing; } + + // In production-like modes, don't auto-rebuild - error instead + throw new RuntimeException( + "Bundle '{$this->bundle_name}' not compiled for production mode. " . + 'Run: php artisan rsx:prod:build' + ); } // Step 2: Mark the bundle we're compiling as already resolved @@ -148,9 +158,18 @@ class BundleCompiler // Step 5: Always split into vendor/app $this->_split_vendor_app(); - // Step 6: Check individual bundle caches - $need_compile = $this->_check_bundle_caches(); - console_debug('BUNDLE', 'Need compile: ' . json_encode($need_compile)); + // Step 6: Check individual bundle caches (or force all if force_build) + if ($force_build) { + $need_compile = ['vendor', 'app']; + $this->cache_keys = []; + foreach ($need_compile as $type) { + $this->cache_keys[$type] = $this->_get_cache_key($type); + } + console_debug('BUNDLE', 'Force build - compiling all types'); + } else { + $need_compile = $this->_check_bundle_caches(); + console_debug('BUNDLE', 'Need compile: ' . json_encode($need_compile)); + } // Step 7-10: Process bundles that need compilation if (!empty($need_compile)) { @@ -205,15 +224,16 @@ class BundleCompiler // Step 13: Return data for render() method (not CLI) // Include CDN assets and proper file paths for HTML generation - $bundle_dir = storage_path('rsx-build/bundles'); $result = []; - // Add CDN assets + // CDN assets are always output as separate tags + // In development mode: loaded directly from CDN URLs + // In production-like modes: served from /_vendor/ (cached locally) if (!empty($this->cdn_assets['js'])) { - $result['cdn_js'] = $this->cdn_assets['js']; + $result['cdn_js'] = $this->_prepare_cdn_assets($this->cdn_assets['js'], 'js'); } if (!empty($this->cdn_assets['css'])) { - $result['cdn_css'] = $this->cdn_assets['css']; + $result['cdn_css'] = $this->_prepare_cdn_assets($this->cdn_assets['css'], 'css'); } // Add public directory assets @@ -224,60 +244,18 @@ class BundleCompiler $result['public_css'] = $this->public_assets['css']; } - // Add bundle file paths for development - if (!$this->is_production) { - if (isset($outputs['vendor_js'])) { - $result['vendor_js_bundle_path'] = $outputs['vendor_js']; - } - if (isset($outputs['app_js'])) { - $result['app_js_bundle_path'] = $outputs['app_js']; - } - if (isset($outputs['vendor_css'])) { - $result['vendor_css_bundle_path'] = $outputs['vendor_css']; - } - if (isset($outputs['app_css'])) { - $result['app_css_bundle_path'] = $outputs['app_css']; - } - } else { - // Production mode - simple concatenation of vendor + app - $js_content = ''; - if (isset($outputs['vendor_js'])) { - $js_content = file_get_contents("{$bundle_dir}/{$outputs['vendor_js']}"); - } - if (isset($outputs['app_js'])) { - // Simple concatenation with newline separator - if ($js_content) { - $js_content .= "\n"; - } - $js_content .= file_get_contents("{$bundle_dir}/{$outputs['app_js']}"); - } - - $css_content = ''; - if (isset($outputs['vendor_css'])) { - $css_content = file_get_contents("{$bundle_dir}/{$outputs['vendor_css']}"); - } - if (isset($outputs['app_css'])) { - // Simple concatenation with newline separator - if ($css_content) { - $css_content .= "\n"; - } - $css_content .= file_get_contents("{$bundle_dir}/{$outputs['app_css']}"); - } - - // Write combined files with content hash - if ($js_content) { - $js_hash = substr(md5($js_content), 0, 16); - $js_file = "app.{$js_hash}.js"; - file_put_contents("{$bundle_dir}/{$js_file}", $js_content); - $result['js_bundle_path'] = $js_file; - } - - if ($css_content) { - $css_hash = substr(md5($css_content), 0, 16); - $css_file = "app.{$css_hash}.css"; - file_put_contents("{$bundle_dir}/{$css_file}", $css_content); - $result['css_bundle_path'] = $css_file; - } + // Always return vendor/app split paths + if (isset($outputs['vendor_js'])) { + $result['vendor_js_bundle_path'] = $outputs['vendor_js']; + } + if (isset($outputs['app_js'])) { + $result['app_js_bundle_path'] = $outputs['app_js']; + } + if (isset($outputs['vendor_css'])) { + $result['vendor_css_bundle_path'] = $outputs['vendor_css']; + } + if (isset($outputs['app_css'])) { + $result['app_css_bundle_path'] = $outputs['app_css']; } // Add config if present @@ -441,28 +419,137 @@ class BundleCompiler return end($parts); } + /** + * Get bundle FQCN from simple class name + */ + protected function _get_bundle_fqcn(string $bundle_name): string + { + // If already a FQCN, return as-is + if (str_contains($bundle_name, '\\')) { + return $bundle_name; + } + + // Look up in Manifest + $metadata = Manifest::php_get_metadata_by_class($bundle_name); + + return $metadata['fqcn']; + } + /** * Check if production bundle already exists */ protected function _check_production_cache(): ?array { $bundle_dir = storage_path('rsx-build/bundles'); - $js_pattern = "{$bundle_dir}/app.*.js"; - $css_pattern = "{$bundle_dir}/app.*.css"; - $js_files = glob($js_pattern); - $css_files = glob($css_pattern); + // Look for split vendor/app files (current output format) + // Future: support merged files when Rsx_Mode::should_merge_bundles() + $vendor_js_pattern = "{$bundle_dir}/{$this->bundle_name}__vendor.*.js"; + $app_js_pattern = "{$bundle_dir}/{$this->bundle_name}__app.*.js"; + $vendor_css_pattern = "{$bundle_dir}/{$this->bundle_name}__vendor.*.css"; + $app_css_pattern = "{$bundle_dir}/{$this->bundle_name}__app.*.css"; - if (!empty($js_files) || !empty($css_files)) { - return [ - 'js_bundle_path' => !empty($js_files) ? basename($js_files[0]) : null, - 'css_bundle_path' => !empty($css_files) ? basename($css_files[0]) : null, + $vendor_js_files = glob($vendor_js_pattern); + $app_js_files = glob($app_js_pattern); + $vendor_css_files = glob($vendor_css_pattern); + $app_css_files = glob($app_css_pattern); + + // Need at least one app file (JS is typically required) + if (!empty($app_js_files)) { + $result = [ + 'vendor_js_bundle_path' => !empty($vendor_js_files) ? basename($vendor_js_files[0]) : null, + 'app_js_bundle_path' => !empty($app_js_files) ? basename($app_js_files[0]) : null, + 'vendor_css_bundle_path' => !empty($vendor_css_files) ? basename($vendor_css_files[0]) : null, + 'app_css_bundle_path' => !empty($app_css_files) ? basename($app_css_files[0]) : null, ]; + + // Also resolve CDN assets - they're served separately via /_vendor/ URLs + // We need to resolve the bundle includes to get CDN asset definitions + $this->_resolve_cdn_assets_only(); + + if (!empty($this->cdn_assets['js'])) { + $result['cdn_js'] = $this->_prepare_cdn_assets($this->cdn_assets['js'], 'js'); + } + if (!empty($this->cdn_assets['css'])) { + $result['cdn_css'] = $this->_prepare_cdn_assets($this->cdn_assets['css'], 'css'); + } + + return $result; } return null; } + /** + * Resolve only CDN assets without full bundle compilation + * + * Used when serving from production cache to get CDN asset URLs + * without re-resolving and re-compiling all bundle files. + */ + protected function _resolve_cdn_assets_only(): void + { + // Process required bundles first (they may have CDN assets) + $required_bundles = config('rsx.required_bundles', []); + $bundle_aliases = config('rsx.bundle_aliases', []); + + foreach ($required_bundles as $alias) { + if (isset($bundle_aliases[$alias])) { + $this->_collect_cdn_assets_from_include($bundle_aliases[$alias]); + } + } + + // Get the bundle's own CDN assets + $fqcn = $this->_get_bundle_fqcn($this->bundle_name); + $definition = $fqcn::define(); + + // Add CDN assets from the bundle definition (same format as main resolution) + if (!empty($definition['cdn_assets'])) { + if (!empty($definition['cdn_assets']['js'])) { + $this->cdn_assets['js'] = array_merge($this->cdn_assets['js'], $definition['cdn_assets']['js']); + } + if (!empty($definition['cdn_assets']['css'])) { + $this->cdn_assets['css'] = array_merge($this->cdn_assets['css'], $definition['cdn_assets']['css']); + } + } + + // Also check for Asset Bundle includes that may have CDN assets + foreach ($definition['include'] ?? [] as $include) { + $this->_collect_cdn_assets_from_include($include); + } + } + + /** + * Collect CDN assets from a bundle include without full resolution + */ + protected function _collect_cdn_assets_from_include($include): void + { + // Handle config array format (from bundle aliases like jquery, lodash) + if (is_array($include) && isset($include['cdn'])) { + foreach ($include['cdn'] as $cdn_item) { + // Determine type from URL extension + $url = $cdn_item['url'] ?? ''; + $type = str_ends_with($url, '.css') ? 'css' : 'js'; + $this->cdn_assets[$type][] = $cdn_item; + } + return; + } + + // Handle class name includes (could be Asset Bundles with CDN assets) + if (is_string($include) && Manifest::php_find_class($include)) { + if (Manifest::php_is_subclass_of($include, 'Rsx_Asset_Bundle_Abstract')) { + $asset_def = $include::define(); + if (!empty($asset_def['cdn_assets'])) { + if (!empty($asset_def['cdn_assets']['js'])) { + $this->cdn_assets['js'] = array_merge($this->cdn_assets['js'], $asset_def['cdn_assets']['js']); + } + if (!empty($asset_def['cdn_assets']['css'])) { + $this->cdn_assets['css'] = array_merge($this->cdn_assets['css'], $asset_def['cdn_assets']['css']); + } + } + } + } + } + /** * Process required bundles (jquery, lodash, jqhtml) */ @@ -1856,7 +1943,18 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '', // Compile JS if (!empty($files['js'])) { - $js_content = $this->_compile_js_files($files['js']); + $js_files = $files['js']; + + // CDN assets are served separately via /_vendor/ URLs, not merged into bundle + // This avoids complex concatenation issues with third-party code + + $js_content = $this->_compile_js_files($js_files); + + // Minify JS in production mode only (strips sourcemaps) + if (Rsx_Mode::is_production()) { + $js_content = Minifier::minify_js($js_content, "{$this->bundle_name}__{$type}.js"); + } + $js_file = "{$this->bundle_name}__{$type}.{$hash}.js"; file_put_contents("{$bundle_dir}/{$js_file}", $js_content); $outputs["{$type}_js"] = $js_file; @@ -1864,7 +1962,18 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '', // Compile CSS if (!empty($files['css'])) { - $css_content = $this->_compile_css_files($files['css']); + $css_files = $files['css']; + + // CDN assets are served separately via /_vendor/ URLs, not merged into bundle + // This avoids complex concatenation issues with third-party code + + $css_content = $this->_compile_css_files($css_files); + + // Minify CSS in production mode only (strips sourcemaps) + if (Rsx_Mode::is_production()) { + $css_content = Minifier::minify_css($css_content, "{$this->bundle_name}__{$type}.css"); + } + $css_file = "{$this->bundle_name}__{$type}.{$hash}.css"; file_put_contents("{$bundle_dir}/{$css_file}", $css_content); $outputs["{$type}_css"] = $css_file; @@ -2189,8 +2298,11 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '', // This preserves dependency sort order - we substitute babel versions during concat foreach ($files as $file) { - // Skip temp files and already processed files - if (str_contains($file, 'storage/rsx-tmp/') || str_contains($file, 'storage/rsx-build/')) { + // Skip temp files, already processed files, and CDN cache files + // CDN files are third-party production code - don't transform them + if (str_contains($file, 'storage/rsx-tmp/') || + str_contains($file, 'storage/rsx-build/') || + str_contains($file, '.cdn-cache/')) { continue; } @@ -2276,11 +2388,6 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '', } @unlink($output_file); - // Minify in production (TODO: preserve source maps when minifying) - if ($this->is_production) { - // TODO: Add minification that preserves source maps - } - return $js; } @@ -2332,12 +2439,103 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '', // Clean up temp file @unlink($output_file); - // Minify in production - if ($this->is_production) { - // TODO: Add minification that preserves source maps + return $css; + } + + /** + * Prepare CDN assets for rendering + * + * In development mode: returns assets as-is (loaded from CDN URLs) + * In production-like modes: ensures assets are cached and adds cached_filename + * so rendering can use /_vendor/{filename} URLs + * + * @param array $assets CDN assets array + * @param string $type 'js' or 'css' + * @return array Prepared assets with cached_filename in production modes + */ + protected function _prepare_cdn_assets(array $assets, string $type): array + { + // In development mode, return as-is (use CDN URLs directly) + if (!$this->is_production) { + return $assets; } - return $css; + // In production-like modes, ensure cached and add filename + $prepared = []; + foreach ($assets as $asset) { + $url = $asset['url'] ?? ''; + if (empty($url)) { + continue; + } + + // Ensure the asset is cached (downloads if not already) + Cdn_Cache::get($url, $type); + + // Add cached filename for /_vendor/ URL generation + $asset['cached_filename'] = Cdn_Cache::get_cache_filename($url, $type); + $prepared[] = $asset; + } + + return $prepared; + } + + /** + * Get local file paths for cached CDN assets (DEPRECATED) + * + * Used in production-like modes to include CDN assets in concat scripts + * for proper sourcemap handling. + * + * @param string $type 'js' or 'css' + * @return array Array of local file paths to cached CDN files + * @deprecated CDN assets are now served via /_vendor/ URLs, not merged into bundles + */ + protected function _get_cdn_cache_file_paths(string $type): array + { + $file_paths = []; + $assets = $this->cdn_assets[$type] ?? []; + + if (empty($assets)) { + return $file_paths; + } + + // Sort assets: jQuery first, then others alphabetically + $jquery_assets = []; + $other_assets = []; + + foreach ($assets as $asset) { + $url = $asset['url'] ?? ''; + if (stripos($url, 'jquery') !== false) { + $jquery_assets[] = $asset; + } else { + $other_assets[] = $asset; + } + } + + usort($other_assets, function ($a, $b) { + return strcmp($a['url'] ?? '', $b['url'] ?? ''); + }); + + $sorted_assets = array_merge($jquery_assets, $other_assets); + + // Get cache file path for each asset (downloads if not cached) + // If download fails, let it throw - CDN assets are required + foreach ($sorted_assets as $asset) { + $url = $asset['url'] ?? ''; + if (empty($url)) { + continue; + } + + // This will download and cache if not already cached + Cdn_Cache::get($url, $type); + + // Get the cache file path + $cache_path = Cdn_Cache::get_cache_path($url, $type); + if (file_exists($cache_path)) { + $file_paths[] = $cache_path; + } + } + + return $file_paths; } /** diff --git a/app/RSpade/Core/Bundle/Cdn_Cache.php b/app/RSpade/Core/Bundle/Cdn_Cache.php new file mode 100755 index 000000000..cc0db6a85 --- /dev/null +++ b/app/RSpade/Core/Bundle/Cdn_Cache.php @@ -0,0 +1,291 @@ +&1', + escapeshellarg($script), + escapeshellarg($base_url), + escapeshellarg($temp_input), + escapeshellarg($temp_output) + ); + + // Use shell_exec with exit code capture + $full_cmd = "({$cmd}); echo \$?"; + $result = shell_exec($full_cmd); + + // Clean up temp input + @unlink($temp_input); + + // Parse exit code from last line + $lines = explode("\n", trim($result ?? '')); + $exit_code = (int) array_pop($lines); + $output = implode("\n", $lines); + + if ($exit_code !== 0) { + // Clean up temp output if it exists + @unlink($temp_output); + + throw new RuntimeException( + "Failed to inline CSS URLs for {$base_url}:\n{$output}" + ); + } + + // Read processed CSS + if (!file_exists($temp_output)) { + throw new RuntimeException( + "CSS URL inlining did not produce output for {$base_url}" + ); + } + + $result = file_get_contents($temp_output); + + // Clean up temp output + @unlink($temp_output); + + return $result; + } + + /** + * Download URL content using curl + */ + private static function _download(string $url): string|false + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_USERAGENT => 'RSpade/1.0', + CURLOPT_SSL_VERIFYPEER => true, + ]); + $content = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($http_code !== 200 || $content === false) { + return false; + } + + return $content; + } + + /** + * Clear the CDN cache + */ + public static function clear(): void + { + $cache_dir = self::_get_cache_dir(); + + if (!is_dir($cache_dir)) { + return; + } + + $files = glob("{$cache_dir}/*.js") + glob("{$cache_dir}/*.css"); + foreach ($files as $file) { + unlink($file); + } + } + + /** + * Get all cached files + * + * @return array Array of ['path' => string, 'url' => string, 'type' => string] + */ + public static function get_cached_files(): array + { + $cache_dir = self::_get_cache_dir(); + + if (!is_dir($cache_dir)) { + return []; + } + + $files = []; + foreach (glob("{$cache_dir}/*") as $file) { + if (is_file($file)) { + $ext = pathinfo($file, PATHINFO_EXTENSION); + if (in_array($ext, ['js', 'css'])) { + // Try to extract URL from file header + $content = file_get_contents($file, false, null, 0, 500); + $url = ''; + if (preg_match('/CDN Source: (.+?) \*/', $content, $matches)) { + $url = $matches[1]; + } + $files[] = [ + 'path' => $file, + 'url' => $url, + 'type' => $ext, + ]; + } + } + } + + return $files; + } +} diff --git a/app/RSpade/Core/Bundle/Minifier.php b/app/RSpade/Core/Bundle/Minifier.php new file mode 100755 index 000000000..25dbdd3c7 --- /dev/null +++ b/app/RSpade/Core/Bundle/Minifier.php @@ -0,0 +1,306 @@ + static::$request_id, + 'method' => 'minify', + 'files' => [ + [ + 'type' => $type, + 'content' => $content, + 'filename' => $filename + ] + ] + ]) . "\n"; + + fwrite($socket, $request); + + $response = fgets($socket); + fclose($socket); + + if (!$response) { + throw new RuntimeException("No response from minify RPC server"); + } + + $result = json_decode($response, true); + + if (!isset($result['results'][$filename])) { + throw new RuntimeException("Invalid response from minify RPC server"); + } + + $file_result = $result['results'][$filename]; + + if ($file_result['status'] === 'error') { + $error = $file_result['error']; + throw new RuntimeException( + "Minification failed for {$filename}: {$error['message']}" + ); + } + + return $file_result['result']; + } + + /** + * Force restart the RPC server + * + * Call this before production builds to ensure code changes take effect. + */ + public static function force_restart(): void + { + static::stop_rpc_server(force: true); + static::$server_started = false; + static::start_rpc_server(); + } + + /** + * Start RPC server for minification + */ + public static function start_rpc_server(): void + { + $socket_path = base_path(self::RPC_SOCKET); + $server_script = base_path(self::RPC_SERVER_SCRIPT); + + if (!file_exists($server_script)) { + throw new RuntimeException("Minify RPC server script not found at {$server_script}"); + } + + // If socket exists, check if it's stale + if (file_exists($socket_path)) { + if (static::ping_rpc_server()) { + static::$server_started = true; + return; + } + static::stop_rpc_server(force: true); + } + + // Ensure socket directory exists + $socket_dir = dirname($socket_path); + if (!is_dir($socket_dir)) { + mkdir($socket_dir, 0755, true); + } + + // Start RPC server + $process = new Process([ + 'node', + $server_script, + '--socket=' . $socket_path + ]); + + $process->setWorkingDirectory(base_path()); + $process->setTimeout(null); + $process->start(); + + static::$rpc_server_process = $process; + + // Wait for server to be ready + $max_wait_ms = 10000; + $wait_interval_ms = 50; + $iterations = $max_wait_ms / $wait_interval_ms; + + for ($i = 0; $i < $iterations; $i++) { + usleep($wait_interval_ms * 1000); + + if (static::ping_rpc_server()) { + static::$server_started = true; + register_shutdown_function([self::class, 'stop_rpc_server']); + return; + } + } + + throw new RuntimeException( + "Minify RPC server failed to start within {$max_wait_ms}ms.\n" . + "Check that Node.js, Terser, and cssnano are installed." + ); + } + + /** + * Ping the RPC server + */ + public static function ping_rpc_server(): bool + { + $socket_path = base_path(self::RPC_SOCKET); + + if (!file_exists($socket_path)) { + return false; + } + + try { + $socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1); + if (!$socket) { + return false; + } + + stream_set_blocking($socket, true); + + static::$request_id++; + $request = json_encode([ + 'id' => static::$request_id, + 'method' => 'ping' + ]) . "\n"; + + fwrite($socket, $request); + $response = fgets($socket); + fclose($socket); + + if (!$response) { + return false; + } + + $result = json_decode($response, true); + return isset($result['result']) && $result['result'] === 'pong'; + } catch (\Exception $e) { + return false; + } + } + + /** + * Stop the RPC server + */ + public static function stop_rpc_server(bool $force = false): void + { + $socket_path = base_path(self::RPC_SOCKET); + + if ($force) { + if (file_exists($socket_path)) { + try { + $socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1); + if ($socket) { + stream_set_blocking($socket, true); + + static::$request_id++; + $request = json_encode([ + 'id' => static::$request_id, + 'method' => 'shutdown' + ]) . "\n"; + + fwrite($socket, $request); + fclose($socket); + } + } catch (\Exception $e) { + // Ignore errors during force shutdown + } + + usleep(100000); // 100ms + + if (file_exists($socket_path)) { + @unlink($socket_path); + } + } + + if (static::$rpc_server_process) { + if (static::$rpc_server_process->isRunning()) { + static::$rpc_server_process->stop(1, SIGTERM); + } + static::$rpc_server_process = null; + } + + static::$server_started = false; + return; + } + + // Graceful shutdown + if (file_exists($socket_path)) { + try { + $socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1); + if ($socket) { + stream_set_blocking($socket, true); + + static::$request_id++; + $request = json_encode([ + 'id' => static::$request_id, + 'method' => 'shutdown' + ]) . "\n"; + + fwrite($socket, $request); + fclose($socket); + } + } catch (\Exception $e) { + // Ignore errors + } + } + + static::$server_started = false; + } +} diff --git a/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php b/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php index 09a288412..7d74f4b20 100644 --- a/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php +++ b/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php @@ -6,6 +6,7 @@ use RuntimeException; use App\RSpade\CodeQuality\RuntimeChecks\BundleErrors; use App\RSpade\Core\Bundle\BundleCompiler; use App\RSpade\Core\Manifest\Manifest; +use App\RSpade\Core\Mode\Rsx_Mode; use App\RSpade\Core\Session\Session; /** @@ -127,7 +128,7 @@ abstract class Rsx_Bundle_Abstract \App\RSpade\Core\Debug\Debugger::dump_console_debug_messages_to_html(); // In development mode, validate path coverage - if (!app()->environment('production')) { + if (Rsx_Mode::is_development()) { static::__validate_path_coverage($bundle_class); } @@ -227,23 +228,13 @@ abstract class Rsx_Bundle_Abstract throw new RuntimeException('BundleCompiler returned non-array: ' . gettype($compiled)); } - // In development, we should have vendor/app split files - // In production, we should have combined files - $is_production = app()->environment('production'); - - if (!$is_production) { - // Development mode - expect vendor/app bundle paths - if (!isset($compiled['vendor_js_bundle_path']) && - !isset($compiled['app_js_bundle_path']) && - !isset($compiled['vendor_css_bundle_path']) && - !isset($compiled['app_css_bundle_path'])) { - throw new RuntimeException('BundleCompiler missing expected vendor/app bundle paths. Got keys: ' . implode(', ', array_keys($compiled))); - } - } else { - // Production mode - expect combined bundle paths - if (!isset($compiled['js_bundle_path']) && !isset($compiled['css_bundle_path'])) { - throw new RuntimeException('BundleCompiler missing expected js/css bundle paths. Got keys: ' . implode(', ', array_keys($compiled))); - } + // Expect vendor/app split files (merging not yet implemented) + // Future: check Rsx_Mode::should_merge_bundles() for combined files + if (!isset($compiled['vendor_js_bundle_path']) && + !isset($compiled['app_js_bundle_path']) && + !isset($compiled['vendor_css_bundle_path']) && + !isset($compiled['app_css_bundle_path'])) { + throw new RuntimeException('BundleCompiler missing expected vendor/app bundle paths. Got keys: ' . implode(', ', array_keys($compiled))); } $html = []; @@ -269,12 +260,16 @@ abstract class Rsx_Bundle_Abstract } // Add runtime data - $rsxapp_data['debug'] = !app()->environment('production'); + $rsxapp_data['debug'] = Rsx_Mode::is_development(); $rsxapp_data['current_controller'] = \App\RSpade\Core\Rsx::get_current_controller(); $rsxapp_data['current_action'] = \App\RSpade\Core\Rsx::get_current_action(); $rsxapp_data['is_auth'] = Session::is_logged_in(); $rsxapp_data['is_spa'] = \App\RSpade\Core\Rsx::is_spa(); - $rsxapp_data['ajax_disable_batching'] = config('rsx.development.ajax_disable_batching', false); + + // Only include ajax_disable_batching in development mode + if (Rsx_Mode::is_development()) { + $rsxapp_data['ajax_disable_batching'] = config('rsx.development.ajax_disable_batching', false); + } // Add current params (always set to reduce state variations) $current_params = \App\RSpade\Core\Rsx::get_current_params(); @@ -305,8 +300,8 @@ abstract class Rsx_Bundle_Abstract $rsxapp_data['server_time'] = \App\RSpade\Core\Time\Rsx_Time::now_iso(); $rsxapp_data['user_timezone'] = \App\RSpade\Core\Time\Rsx_Time::get_user_timezone(); - // Add console_debug config in non-production mode - if (!app()->environment('production')) { + // Add console_debug config only in development mode + if (Rsx_Mode::should_include_debug_info()) { $console_debug_config = config('rsx.console_debug', []); // Build console_debug settings @@ -371,10 +366,8 @@ abstract class Rsx_Bundle_Abstract // Filter out keys starting with single underscore (but allow double underscore like __MODEL) $rsxapp_data = static::__filter_underscore_keys($rsxapp_data); - // Pretty print JSON in non-production environments - $rsxapp_json = app()->environment('production') - ? json_encode($rsxapp_data, JSON_UNESCAPED_SLASHES) - : json_encode($rsxapp_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + // Always pretty print rsxapp for debuggability in browser dev tools + $rsxapp_json = json_encode($rsxapp_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $html[] = ''; @@ -413,13 +406,20 @@ abstract class Rsx_Bundle_Abstract // Add CSS: jQuery first, then others foreach (array_merge($jquery_css, $other_css) as $asset) { - $tag = ''; + } else { + // Development mode: use CDN URL directly + $tag = ''; + } else { + // Development mode: use CDN URL directly + $tag = ' + +``` + +- `dist/csstree.esm.js` – minified ES module +```html + +``` + +One of CDN services like `unpkg` or `jsDelivr` can be used. By default (for short path) a ESM version is exposing. For IIFE version a full path to a bundle should be specified: + +```html + + + + + + +``` + ## Top level API -![API map](https://cdn.rawgit.com/csstree/csstree/1.0/docs/api-map.svg) +![API map](https://cdn.rawgit.com/csstree/csstree/aaf327e/docs/api-map.svg) ## License diff --git a/node_modules/css-tree/cjs/convertor/create.cjs b/node_modules/css-tree/cjs/convertor/create.cjs new file mode 100644 index 000000000..55c655b24 --- /dev/null +++ b/node_modules/css-tree/cjs/convertor/create.cjs @@ -0,0 +1,32 @@ +'use strict'; + +const List = require('../utils/List.cjs'); + +function createConvertor(walk) { + return { + fromPlainObject(ast) { + walk(ast, { + enter(node) { + if (node.children && node.children instanceof List.List === false) { + node.children = new List.List().fromArray(node.children); + } + } + }); + + return ast; + }, + toPlainObject(ast) { + walk(ast, { + leave(node) { + if (node.children && node.children instanceof List.List) { + node.children = node.children.toArray(); + } + } + }); + + return ast; + } + }; +} + +exports.createConvertor = createConvertor; diff --git a/node_modules/css-tree/cjs/convertor/index.cjs b/node_modules/css-tree/cjs/convertor/index.cjs new file mode 100644 index 000000000..665427855 --- /dev/null +++ b/node_modules/css-tree/cjs/convertor/index.cjs @@ -0,0 +1,8 @@ +'use strict'; + +const create = require('./create.cjs'); +const index$1 = require('../walker/index.cjs'); + +const index = create.createConvertor(index$1); + +module.exports = index; diff --git a/node_modules/css-tree/cjs/data-patch.cjs b/node_modules/css-tree/cjs/data-patch.cjs new file mode 100755 index 000000000..9103ea4c2 --- /dev/null +++ b/node_modules/css-tree/cjs/data-patch.cjs @@ -0,0 +1,7 @@ +'use strict'; + +const patch = require('../data/patch.json'); + +const patch$1 = patch; + +module.exports = patch$1; diff --git a/node_modules/css-tree/cjs/data.cjs b/node_modules/css-tree/cjs/data.cjs new file mode 100755 index 000000000..258ac6a31 --- /dev/null +++ b/node_modules/css-tree/cjs/data.cjs @@ -0,0 +1,120 @@ +'use strict'; + +const dataPatch = require('./data-patch.cjs'); + +const mdnAtrules = require('mdn-data/css/at-rules.json'); +const mdnProperties = require('mdn-data/css/properties.json'); +const mdnSyntaxes = require('mdn-data/css/syntaxes.json'); + +const hasOwn = Object.hasOwn || ((object, property) => Object.prototype.hasOwnProperty.call(object, property)); +const extendSyntax = /^\s*\|\s*/; + +function preprocessAtrules(dict) { + const result = Object.create(null); + + for (const [atruleName, atrule] of Object.entries(dict)) { + let descriptors = null; + + if (atrule.descriptors) { + descriptors = Object.create(null); + + for (const [name, descriptor] of Object.entries(atrule.descriptors)) { + descriptors[name] = descriptor.syntax; + } + } + + result[atruleName.substr(1)] = { + prelude: atrule.syntax.trim().replace(/\{(.|\s)+\}/, '').match(/^@\S+\s+([^;\{]*)/)[1].trim() || null, + descriptors + }; + } + + return result; +} + +function patchDictionary(dict, patchDict) { + const result = Object.create(null); + + // copy all syntaxes for an original dict + for (const [key, value] of Object.entries(dict)) { + if (value) { + result[key] = value.syntax || value; + } + } + + // apply a patch + for (const key of Object.keys(patchDict)) { + if (hasOwn(dict, key)) { + if (patchDict[key].syntax) { + result[key] = extendSyntax.test(patchDict[key].syntax) + ? result[key] + ' ' + patchDict[key].syntax.trim() + : patchDict[key].syntax; + } else { + delete result[key]; + } + } else { + if (patchDict[key].syntax) { + result[key] = patchDict[key].syntax.replace(extendSyntax, ''); + } + } + } + + return result; +} + +function preprocessPatchAtrulesDescritors(declarations) { + const result = {}; + + for (const [key, value] of Object.entries(declarations || {})) { + result[key] = typeof value === 'string' + ? { syntax: value } + : value; + } + + return result; +} + +function patchAtrules(dict, patchDict) { + const result = {}; + + // copy all syntaxes for an original dict + for (const key in dict) { + if (patchDict[key] === null) { + continue; + } + + const atrulePatch = patchDict[key] || {}; + + result[key] = { + prelude: key in patchDict && 'prelude' in atrulePatch + ? atrulePatch.prelude + : dict[key].prelude || null, + descriptors: patchDictionary( + dict[key].descriptors || {}, + preprocessPatchAtrulesDescritors(atrulePatch.descriptors) + ) + }; + } + + // apply a patch + for (const [key, atrulePatch] of Object.entries(patchDict)) { + if (atrulePatch && !hasOwn(dict, key)) { + result[key] = { + prelude: atrulePatch.prelude || null, + descriptors: atrulePatch.descriptors + ? patchDictionary({}, preprocessPatchAtrulesDescritors(atrulePatch.descriptors)) + : null + }; + } + } + + return result; +} + +const definitions = { + types: patchDictionary(mdnSyntaxes, dataPatch.types), + atrules: patchAtrules(preprocessAtrules(mdnAtrules), dataPatch.atrules), + properties: patchDictionary(mdnProperties, dataPatch.properties) +}; + +module.exports = definitions; diff --git a/node_modules/css-tree/cjs/definition-syntax/SyntaxError.cjs b/node_modules/css-tree/cjs/definition-syntax/SyntaxError.cjs new file mode 100644 index 000000000..d24e7ceda --- /dev/null +++ b/node_modules/css-tree/cjs/definition-syntax/SyntaxError.cjs @@ -0,0 +1,16 @@ +'use strict'; + +const createCustomError = require('../utils/create-custom-error.cjs'); + +function SyntaxError(message, input, offset) { + return Object.assign(createCustomError.createCustomError('SyntaxError', message), { + input, + offset, + rawMessage: message, + message: message + '\n' + + ' ' + input + '\n' + + '--' + new Array((offset || input.length) + 1).join('-') + '^' + }); +} + +exports.SyntaxError = SyntaxError; diff --git a/node_modules/css-tree/cjs/definition-syntax/generate.cjs b/node_modules/css-tree/cjs/definition-syntax/generate.cjs new file mode 100644 index 000000000..ff9f0ad47 --- /dev/null +++ b/node_modules/css-tree/cjs/definition-syntax/generate.cjs @@ -0,0 +1,139 @@ +'use strict'; + +function noop(value) { + return value; +} + +function generateMultiplier(multiplier) { + const { min, max, comma } = multiplier; + + if (min === 0 && max === 0) { + return comma ? '#?' : '*'; + } + + if (min === 0 && max === 1) { + return '?'; + } + + if (min === 1 && max === 0) { + return comma ? '#' : '+'; + } + + if (min === 1 && max === 1) { + return ''; + } + + return ( + (comma ? '#' : '') + + (min === max + ? '{' + min + '}' + : '{' + min + ',' + (max !== 0 ? max : '') + '}' + ) + ); +} + +function generateTypeOpts(node) { + switch (node.type) { + case 'Range': + return ( + ' [' + + (node.min === null ? '-∞' : node.min) + + ',' + + (node.max === null ? '∞' : node.max) + + ']' + ); + + default: + throw new Error('Unknown node type `' + node.type + '`'); + } +} + +function generateSequence(node, decorate, forceBraces, compact) { + const combinator = node.combinator === ' ' || compact ? node.combinator : ' ' + node.combinator + ' '; + const result = node.terms + .map(term => internalGenerate(term, decorate, forceBraces, compact)) + .join(combinator); + + if (node.explicit || forceBraces) { + return (compact || result[0] === ',' ? '[' : '[ ') + result + (compact ? ']' : ' ]'); + } + + return result; +} + +function internalGenerate(node, decorate, forceBraces, compact) { + let result; + + switch (node.type) { + case 'Group': + result = + generateSequence(node, decorate, forceBraces, compact) + + (node.disallowEmpty ? '!' : ''); + break; + + case 'Multiplier': + // return since node is a composition + return ( + internalGenerate(node.term, decorate, forceBraces, compact) + + decorate(generateMultiplier(node), node) + ); + + case 'Boolean': + result = ''; + break; + + case 'Type': + result = '<' + node.name + (node.opts ? decorate(generateTypeOpts(node.opts), node.opts) : '') + '>'; + break; + + case 'Property': + result = '<\'' + node.name + '\'>'; + break; + + case 'Keyword': + result = node.name; + break; + + case 'AtKeyword': + result = '@' + node.name; + break; + + case 'Function': + result = node.name + '('; + break; + + case 'String': + case 'Token': + result = node.value; + break; + + case 'Comma': + result = ','; + break; + + default: + throw new Error('Unknown node type `' + node.type + '`'); + } + + return decorate(result, node); +} + +function generate(node, options) { + let decorate = noop; + let forceBraces = false; + let compact = false; + + if (typeof options === 'function') { + decorate = options; + } else if (options) { + forceBraces = Boolean(options.forceBraces); + compact = Boolean(options.compact); + if (typeof options.decorate === 'function') { + decorate = options.decorate; + } + } + + return internalGenerate(node, decorate, forceBraces, compact); +} + +exports.generate = generate; diff --git a/node_modules/css-tree/cjs/definition-syntax/index.cjs b/node_modules/css-tree/cjs/definition-syntax/index.cjs new file mode 100644 index 000000000..0afb505c9 --- /dev/null +++ b/node_modules/css-tree/cjs/definition-syntax/index.cjs @@ -0,0 +1,13 @@ +'use strict'; + +const SyntaxError = require('./SyntaxError.cjs'); +const generate = require('./generate.cjs'); +const parse = require('./parse.cjs'); +const walk = require('./walk.cjs'); + + + +exports.SyntaxError = SyntaxError.SyntaxError; +exports.generate = generate.generate; +exports.parse = parse.parse; +exports.walk = walk.walk; diff --git a/node_modules/css-tree/cjs/definition-syntax/parse.cjs b/node_modules/css-tree/cjs/definition-syntax/parse.cjs new file mode 100644 index 000000000..b17b26792 --- /dev/null +++ b/node_modules/css-tree/cjs/definition-syntax/parse.cjs @@ -0,0 +1,556 @@ +'use strict'; + +const scanner = require('./scanner.cjs'); + +const TAB = 9; +const N = 10; +const F = 12; +const R = 13; +const SPACE = 32; +const EXCLAMATIONMARK = 33; // ! +const NUMBERSIGN = 35; // # +const AMPERSAND = 38; // & +const APOSTROPHE = 39; // ' +const LEFTPARENTHESIS = 40; // ( +const RIGHTPARENTHESIS = 41; // ) +const ASTERISK = 42; // * +const PLUSSIGN = 43; // + +const COMMA = 44; // , +const HYPERMINUS = 45; // - +const LESSTHANSIGN = 60; // < +const GREATERTHANSIGN = 62; // > +const QUESTIONMARK = 63; // ? +const COMMERCIALAT = 64; // @ +const LEFTSQUAREBRACKET = 91; // [ +const RIGHTSQUAREBRACKET = 93; // ] +const LEFTCURLYBRACKET = 123; // { +const VERTICALLINE = 124; // | +const RIGHTCURLYBRACKET = 125; // } +const INFINITY = 8734; // ∞ +const COMBINATOR_PRECEDENCE = { + ' ': 1, + '&&': 2, + '||': 3, + '|': 4 +}; + +function readMultiplierRange(scanner) { + let min = null; + let max = null; + + scanner.eat(LEFTCURLYBRACKET); + scanner.skipWs(); + + min = scanner.scanNumber(scanner); + scanner.skipWs(); + + if (scanner.charCode() === COMMA) { + scanner.pos++; + scanner.skipWs(); + + if (scanner.charCode() !== RIGHTCURLYBRACKET) { + max = scanner.scanNumber(scanner); + scanner.skipWs(); + } + } else { + max = min; + } + + scanner.eat(RIGHTCURLYBRACKET); + + return { + min: Number(min), + max: max ? Number(max) : 0 + }; +} + +function readMultiplier(scanner) { + let range = null; + let comma = false; + + switch (scanner.charCode()) { + case ASTERISK: + scanner.pos++; + + range = { + min: 0, + max: 0 + }; + + break; + + case PLUSSIGN: + scanner.pos++; + + range = { + min: 1, + max: 0 + }; + + break; + + case QUESTIONMARK: + scanner.pos++; + + range = { + min: 0, + max: 1 + }; + + break; + + case NUMBERSIGN: + scanner.pos++; + + comma = true; + + if (scanner.charCode() === LEFTCURLYBRACKET) { + range = readMultiplierRange(scanner); + } else if (scanner.charCode() === QUESTIONMARK) { + // https://www.w3.org/TR/css-values-4/#component-multipliers + // > the # and ? multipliers may be stacked as #? + // In this case just treat "#?" as a single multiplier + // { min: 0, max: 0, comma: true } + scanner.pos++; + range = { + min: 0, + max: 0 + }; + } else { + range = { + min: 1, + max: 0 + }; + } + + break; + + case LEFTCURLYBRACKET: + range = readMultiplierRange(scanner); + break; + + default: + return null; + } + + return { + type: 'Multiplier', + comma, + min: range.min, + max: range.max, + term: null + }; +} + +function maybeMultiplied(scanner, node) { + const multiplier = readMultiplier(scanner); + + if (multiplier !== null) { + multiplier.term = node; + + // https://www.w3.org/TR/css-values-4/#component-multipliers + // > The + and # multipliers may be stacked as +#; + // Represent "+#" as nested multipliers: + // { ..., + // term: { + // ..., + // term: node + // } + // } + if (scanner.charCode() === NUMBERSIGN && + scanner.charCodeAt(scanner.pos - 1) === PLUSSIGN) { + return maybeMultiplied(scanner, multiplier); + } + + return multiplier; + } + + return node; +} + +function maybeToken(scanner) { + const ch = scanner.peek(); + + if (ch === '') { + return null; + } + + return maybeMultiplied(scanner, { + type: 'Token', + value: ch + }); +} + +function readProperty(scanner) { + let name; + + scanner.eat(LESSTHANSIGN); + scanner.eat(APOSTROPHE); + + name = scanner.scanWord(); + + scanner.eat(APOSTROPHE); + scanner.eat(GREATERTHANSIGN); + + return maybeMultiplied(scanner, { + type: 'Property', + name + }); +} + +// https://drafts.csswg.org/css-values-3/#numeric-ranges +// 4.1. Range Restrictions and Range Definition Notation +// +// Range restrictions can be annotated in the numeric type notation using CSS bracketed +// range notation—[min,max]—within the angle brackets, after the identifying keyword, +// indicating a closed range between (and including) min and max. +// For example, indicates an integer between 0 and 10, inclusive. +function readTypeRange(scanner) { + // use null for Infinity to make AST format JSON serializable/deserializable + let min = null; // -Infinity + let max = null; // Infinity + let sign = 1; + + scanner.eat(LEFTSQUAREBRACKET); + + if (scanner.charCode() === HYPERMINUS) { + scanner.peek(); + sign = -1; + } + + if (sign == -1 && scanner.charCode() === INFINITY) { + scanner.peek(); + } else { + min = sign * Number(scanner.scanNumber(scanner)); + + if (scanner.isNameCharCode()) { + min += scanner.scanWord(); + } + } + + scanner.skipWs(); + scanner.eat(COMMA); + scanner.skipWs(); + + if (scanner.charCode() === INFINITY) { + scanner.peek(); + } else { + sign = 1; + + if (scanner.charCode() === HYPERMINUS) { + scanner.peek(); + sign = -1; + } + + max = sign * Number(scanner.scanNumber(scanner)); + + if (scanner.isNameCharCode()) { + max += scanner.scanWord(); + } + } + + scanner.eat(RIGHTSQUAREBRACKET); + + return { + type: 'Range', + min, + max + }; +} + +function readType(scanner) { + let name; + let opts = null; + + scanner.eat(LESSTHANSIGN); + name = scanner.scanWord(); + + // https://drafts.csswg.org/css-values-5/#boolean + if (name === 'boolean-expr') { + scanner.eat(LEFTSQUAREBRACKET); + + const implicitGroup = readImplicitGroup(scanner, RIGHTSQUAREBRACKET); + + scanner.eat(RIGHTSQUAREBRACKET); + scanner.eat(GREATERTHANSIGN); + + return maybeMultiplied(scanner, { + type: 'Boolean', + term: implicitGroup.terms.length === 1 + ? implicitGroup.terms[0] + : implicitGroup + }); + } + + if (scanner.charCode() === LEFTPARENTHESIS && + scanner.nextCharCode() === RIGHTPARENTHESIS) { + scanner.pos += 2; + name += '()'; + } + + if (scanner.charCodeAt(scanner.findWsEnd(scanner.pos)) === LEFTSQUAREBRACKET) { + scanner.skipWs(); + opts = readTypeRange(scanner); + } + + scanner.eat(GREATERTHANSIGN); + + return maybeMultiplied(scanner, { + type: 'Type', + name, + opts + }); +} + +function readKeywordOrFunction(scanner) { + const name = scanner.scanWord(); + + if (scanner.charCode() === LEFTPARENTHESIS) { + scanner.pos++; + + return { + type: 'Function', + name + }; + } + + return maybeMultiplied(scanner, { + type: 'Keyword', + name + }); +} + +function regroupTerms(terms, combinators) { + function createGroup(terms, combinator) { + return { + type: 'Group', + terms, + combinator, + disallowEmpty: false, + explicit: false + }; + } + + let combinator; + + combinators = Object.keys(combinators) + .sort((a, b) => COMBINATOR_PRECEDENCE[a] - COMBINATOR_PRECEDENCE[b]); + + while (combinators.length > 0) { + combinator = combinators.shift(); + + let i = 0; + let subgroupStart = 0; + + for (; i < terms.length; i++) { + const term = terms[i]; + + if (term.type === 'Combinator') { + if (term.value === combinator) { + if (subgroupStart === -1) { + subgroupStart = i - 1; + } + terms.splice(i, 1); + i--; + } else { + if (subgroupStart !== -1 && i - subgroupStart > 1) { + terms.splice( + subgroupStart, + i - subgroupStart, + createGroup(terms.slice(subgroupStart, i), combinator) + ); + i = subgroupStart + 1; + } + subgroupStart = -1; + } + } + } + + if (subgroupStart !== -1 && combinators.length) { + terms.splice( + subgroupStart, + i - subgroupStart, + createGroup(terms.slice(subgroupStart, i), combinator) + ); + } + } + + return combinator; +} + +function readImplicitGroup(scanner, stopCharCode) { + const combinators = Object.create(null); + const terms = []; + let token; + let prevToken = null; + let prevTokenPos = scanner.pos; + + while (scanner.charCode() !== stopCharCode && (token = peek(scanner, stopCharCode))) { + if (token.type !== 'Spaces') { + if (token.type === 'Combinator') { + // check for combinator in group beginning and double combinator sequence + if (prevToken === null || prevToken.type === 'Combinator') { + scanner.pos = prevTokenPos; + scanner.error('Unexpected combinator'); + } + + combinators[token.value] = true; + } else if (prevToken !== null && prevToken.type !== 'Combinator') { + combinators[' '] = true; // a b + terms.push({ + type: 'Combinator', + value: ' ' + }); + } + + terms.push(token); + prevToken = token; + prevTokenPos = scanner.pos; + } + } + + // check for combinator in group ending + if (prevToken !== null && prevToken.type === 'Combinator') { + scanner.pos -= prevTokenPos; + scanner.error('Unexpected combinator'); + } + + return { + type: 'Group', + terms, + combinator: regroupTerms(terms, combinators) || ' ', + disallowEmpty: false, + explicit: false + }; +} + +function readGroup(scanner, stopCharCode) { + let result; + + scanner.eat(LEFTSQUAREBRACKET); + result = readImplicitGroup(scanner, stopCharCode); + scanner.eat(RIGHTSQUAREBRACKET); + + result.explicit = true; + + if (scanner.charCode() === EXCLAMATIONMARK) { + scanner.pos++; + result.disallowEmpty = true; + } + + return result; +} + +function peek(scanner, stopCharCode) { + let code = scanner.charCode(); + + switch (code) { + case RIGHTSQUAREBRACKET: + // don't eat, stop scan a group + break; + + case LEFTSQUAREBRACKET: + return maybeMultiplied(scanner, readGroup(scanner, stopCharCode)); + + case LESSTHANSIGN: + return scanner.nextCharCode() === APOSTROPHE + ? readProperty(scanner) + : readType(scanner); + + case VERTICALLINE: + return { + type: 'Combinator', + value: scanner.substringToPos( + scanner.pos + (scanner.nextCharCode() === VERTICALLINE ? 2 : 1) + ) + }; + + case AMPERSAND: + scanner.pos++; + scanner.eat(AMPERSAND); + + return { + type: 'Combinator', + value: '&&' + }; + + case COMMA: + scanner.pos++; + return { + type: 'Comma' + }; + + case APOSTROPHE: + return maybeMultiplied(scanner, { + type: 'String', + value: scanner.scanString() + }); + + case SPACE: + case TAB: + case N: + case R: + case F: + return { + type: 'Spaces', + value: scanner.scanSpaces() + }; + + case COMMERCIALAT: + code = scanner.nextCharCode(); + + if (scanner.isNameCharCode(code)) { + scanner.pos++; + return { + type: 'AtKeyword', + name: scanner.scanWord() + }; + } + + return maybeToken(scanner); + + case ASTERISK: + case PLUSSIGN: + case QUESTIONMARK: + case NUMBERSIGN: + case EXCLAMATIONMARK: + // prohibited tokens (used as a multiplier start) + break; + + case LEFTCURLYBRACKET: + // LEFTCURLYBRACKET is allowed since mdn/data uses it w/o quoting + // check next char isn't a number, because it's likely a disjoined multiplier + code = scanner.nextCharCode(); + + if (code < 48 || code > 57) { + return maybeToken(scanner); + } + + break; + + default: + if (scanner.isNameCharCode(code)) { + return readKeywordOrFunction(scanner); + } + + return maybeToken(scanner); + } +} + +function parse(source) { + const scanner$1 = new scanner.Scanner(source); + const result = readImplicitGroup(scanner$1); + + if (scanner$1.pos !== source.length) { + scanner$1.error('Unexpected input'); + } + + // reduce redundant groups with single group term + if (result.terms.length === 1 && result.terms[0].type === 'Group') { + return result.terms[0]; + } + + return result; +} + +exports.parse = parse; diff --git a/node_modules/css-tree/cjs/definition-syntax/scanner.cjs b/node_modules/css-tree/cjs/definition-syntax/scanner.cjs new file mode 100644 index 000000000..0bad36a4a --- /dev/null +++ b/node_modules/css-tree/cjs/definition-syntax/scanner.cjs @@ -0,0 +1,113 @@ +'use strict'; + +const SyntaxError = require('./SyntaxError.cjs'); + +const TAB = 9; +const N = 10; +const F = 12; +const R = 13; +const SPACE = 32; +const NAME_CHAR = new Uint8Array(128).map((_, idx) => + /[a-zA-Z0-9\-]/.test(String.fromCharCode(idx)) ? 1 : 0 +); + +class Scanner { + constructor(str) { + this.str = str; + this.pos = 0; + } + + charCodeAt(pos) { + return pos < this.str.length ? this.str.charCodeAt(pos) : 0; + } + charCode() { + return this.charCodeAt(this.pos); + } + isNameCharCode(code = this.charCode()) { + return code < 128 && NAME_CHAR[code] === 1; + } + nextCharCode() { + return this.charCodeAt(this.pos + 1); + } + nextNonWsCode(pos) { + return this.charCodeAt(this.findWsEnd(pos)); + } + skipWs() { + this.pos = this.findWsEnd(this.pos); + } + findWsEnd(pos) { + for (; pos < this.str.length; pos++) { + const code = this.str.charCodeAt(pos); + if (code !== R && code !== N && code !== F && code !== SPACE && code !== TAB) { + break; + } + } + + return pos; + } + substringToPos(end) { + return this.str.substring(this.pos, this.pos = end); + } + eat(code) { + if (this.charCode() !== code) { + this.error('Expect `' + String.fromCharCode(code) + '`'); + } + + this.pos++; + } + peek() { + return this.pos < this.str.length ? this.str.charAt(this.pos++) : ''; + } + error(message) { + throw new SyntaxError.SyntaxError(message, this.str, this.pos); + } + + scanSpaces() { + return this.substringToPos(this.findWsEnd(this.pos)); + } + scanWord() { + let end = this.pos; + + for (; end < this.str.length; end++) { + const code = this.str.charCodeAt(end); + if (code >= 128 || NAME_CHAR[code] === 0) { + break; + } + } + + if (this.pos === end) { + this.error('Expect a keyword'); + } + + return this.substringToPos(end); + } + scanNumber() { + let end = this.pos; + + for (; end < this.str.length; end++) { + const code = this.str.charCodeAt(end); + + if (code < 48 || code > 57) { + break; + } + } + + if (this.pos === end) { + this.error('Expect a number'); + } + + return this.substringToPos(end); + } + scanString() { + const end = this.str.indexOf('\'', this.pos + 1); + + if (end === -1) { + this.pos = this.str.length; + this.error('Expect an apostrophe'); + } + + return this.substringToPos(end + 1); + } +} + +exports.Scanner = Scanner; diff --git a/node_modules/css-tree/cjs/definition-syntax/tokenizer.cjs b/node_modules/css-tree/cjs/definition-syntax/tokenizer.cjs new file mode 100644 index 000000000..2b934bd9b --- /dev/null +++ b/node_modules/css-tree/cjs/definition-syntax/tokenizer.cjs @@ -0,0 +1,59 @@ +'use strict'; + +const SyntaxError = require('./SyntaxError.cjs'); + +const TAB = 9; +const N = 10; +const F = 12; +const R = 13; +const SPACE = 32; + +class Tokenizer { + constructor(str) { + this.str = str; + this.pos = 0; + } + charCodeAt(pos) { + return pos < this.str.length ? this.str.charCodeAt(pos) : 0; + } + charCode() { + return this.charCodeAt(this.pos); + } + nextCharCode() { + return this.charCodeAt(this.pos + 1); + } + nextNonWsCode(pos) { + return this.charCodeAt(this.findWsEnd(pos)); + } + skipWs() { + this.pos = this.findWsEnd(this.pos); + } + findWsEnd(pos) { + for (; pos < this.str.length; pos++) { + const code = this.str.charCodeAt(pos); + if (code !== R && code !== N && code !== F && code !== SPACE && code !== TAB) { + break; + } + } + + return pos; + } + substringToPos(end) { + return this.str.substring(this.pos, this.pos = end); + } + eat(code) { + if (this.charCode() !== code) { + this.error('Expect `' + String.fromCharCode(code) + '`'); + } + + this.pos++; + } + peek() { + return this.pos < this.str.length ? this.str.charAt(this.pos++) : ''; + } + error(message) { + throw new SyntaxError.SyntaxError(message, this.str, this.pos); + } +} + +exports.Tokenizer = Tokenizer; diff --git a/node_modules/css-tree/cjs/definition-syntax/walk.cjs b/node_modules/css-tree/cjs/definition-syntax/walk.cjs new file mode 100644 index 000000000..fdba06572 --- /dev/null +++ b/node_modules/css-tree/cjs/definition-syntax/walk.cjs @@ -0,0 +1,57 @@ +'use strict'; + +const noop = function() {}; + +function ensureFunction(value) { + return typeof value === 'function' ? value : noop; +} + +function walk(node, options, context) { + function walk(node) { + enter.call(context, node); + + switch (node.type) { + case 'Group': + node.terms.forEach(walk); + break; + + case 'Multiplier': + case 'Boolean': + walk(node.term); + break; + + case 'Type': + case 'Property': + case 'Keyword': + case 'AtKeyword': + case 'Function': + case 'String': + case 'Token': + case 'Comma': + break; + + default: + throw new Error('Unknown type: ' + node.type); + } + + leave.call(context, node); + } + + let enter = noop; + let leave = noop; + + if (typeof options === 'function') { + enter = options; + } else if (options) { + enter = ensureFunction(options.enter); + leave = ensureFunction(options.leave); + } + + if (enter === noop && leave === noop) { + throw new Error('Neither `enter` nor `leave` walker handler is set or both aren\'t a function'); + } + + walk(node); +} + +exports.walk = walk; diff --git a/node_modules/css-tree/cjs/generator/create.cjs b/node_modules/css-tree/cjs/generator/create.cjs new file mode 100644 index 000000000..87a54b23b --- /dev/null +++ b/node_modules/css-tree/cjs/generator/create.cjs @@ -0,0 +1,102 @@ +'use strict'; + +const index = require('../tokenizer/index.cjs'); +const sourceMap = require('./sourceMap.cjs'); +const tokenBefore = require('./token-before.cjs'); +const types = require('../tokenizer/types.cjs'); + +const REVERSESOLIDUS = 0x005c; // U+005C REVERSE SOLIDUS (\) + +function processChildren(node, delimeter) { + if (typeof delimeter === 'function') { + let prev = null; + + node.children.forEach(node => { + if (prev !== null) { + delimeter.call(this, prev); + } + + this.node(node); + prev = node; + }); + + return; + } + + node.children.forEach(this.node, this); +} + +function processChunk(chunk) { + index.tokenize(chunk, (type, start, end) => { + this.token(type, chunk.slice(start, end)); + }); +} + +function createGenerator(config) { + const types$1 = new Map(); + + for (let [name, item] of Object.entries(config.node)) { + const fn = item.generate || item; + + if (typeof fn === 'function') { + types$1.set(name, item.generate || item); + } + } + + return function(node, options) { + let buffer = ''; + let prevCode = 0; + let handlers = { + node(node) { + if (types$1.has(node.type)) { + types$1.get(node.type).call(publicApi, node); + } else { + throw new Error('Unknown node type: ' + node.type); + } + }, + tokenBefore: tokenBefore.safe, + token(type, value) { + prevCode = this.tokenBefore(prevCode, type, value); + + this.emit(value, type, false); + + if (type === types.Delim && value.charCodeAt(0) === REVERSESOLIDUS) { + this.emit('\n', types.WhiteSpace, true); + } + }, + emit(value) { + buffer += value; + }, + result() { + return buffer; + } + }; + + if (options) { + if (typeof options.decorator === 'function') { + handlers = options.decorator(handlers); + } + + if (options.sourceMap) { + handlers = sourceMap.generateSourceMap(handlers); + } + + if (options.mode in tokenBefore) { + handlers.tokenBefore = tokenBefore[options.mode]; + } + } + + const publicApi = { + node: (node) => handlers.node(node), + children: processChildren, + token: (type, value) => handlers.token(type, value), + tokenize: processChunk + }; + + handlers.node(node); + + return handlers.result(); + }; +} + +exports.createGenerator = createGenerator; diff --git a/node_modules/css-tree/cjs/generator/index.cjs b/node_modules/css-tree/cjs/generator/index.cjs new file mode 100644 index 000000000..5c87cd341 --- /dev/null +++ b/node_modules/css-tree/cjs/generator/index.cjs @@ -0,0 +1,8 @@ +'use strict'; + +const create = require('./create.cjs'); +const generator = require('../syntax/config/generator.cjs'); + +const index = create.createGenerator(generator); + +module.exports = index; diff --git a/node_modules/css-tree/cjs/generator/sourceMap.cjs b/node_modules/css-tree/cjs/generator/sourceMap.cjs new file mode 100644 index 000000000..efbc5b9e5 --- /dev/null +++ b/node_modules/css-tree/cjs/generator/sourceMap.cjs @@ -0,0 +1,96 @@ +'use strict'; + +const sourceMapGenerator_js = require('source-map-js/lib/source-map-generator.js'); + +const trackNodes = new Set(['Atrule', 'Selector', 'Declaration']); + +function generateSourceMap(handlers) { + const map = new sourceMapGenerator_js.SourceMapGenerator(); + const generated = { + line: 1, + column: 0 + }; + const original = { + line: 0, // should be zero to add first mapping + column: 0 + }; + const activatedGenerated = { + line: 1, + column: 0 + }; + const activatedMapping = { + generated: activatedGenerated + }; + let line = 1; + let column = 0; + let sourceMappingActive = false; + + const origHandlersNode = handlers.node; + handlers.node = function(node) { + if (node.loc && node.loc.start && trackNodes.has(node.type)) { + const nodeLine = node.loc.start.line; + const nodeColumn = node.loc.start.column - 1; + + if (original.line !== nodeLine || + original.column !== nodeColumn) { + original.line = nodeLine; + original.column = nodeColumn; + + generated.line = line; + generated.column = column; + + if (sourceMappingActive) { + sourceMappingActive = false; + if (generated.line !== activatedGenerated.line || + generated.column !== activatedGenerated.column) { + map.addMapping(activatedMapping); + } + } + + sourceMappingActive = true; + map.addMapping({ + source: node.loc.source, + original, + generated + }); + } + } + + origHandlersNode.call(this, node); + + if (sourceMappingActive && trackNodes.has(node.type)) { + activatedGenerated.line = line; + activatedGenerated.column = column; + } + }; + + const origHandlersEmit = handlers.emit; + handlers.emit = function(value, type, auto) { + for (let i = 0; i < value.length; i++) { + if (value.charCodeAt(i) === 10) { // \n + line++; + column = 0; + } else { + column++; + } + } + + origHandlersEmit(value, type, auto); + }; + + const origHandlersResult = handlers.result; + handlers.result = function() { + if (sourceMappingActive) { + map.addMapping(activatedMapping); + } + + return { + css: origHandlersResult(), + map + }; + }; + + return handlers; +} + +exports.generateSourceMap = generateSourceMap; diff --git a/node_modules/css-tree/cjs/generator/token-before.cjs b/node_modules/css-tree/cjs/generator/token-before.cjs new file mode 100644 index 000000000..87bf4a3e2 --- /dev/null +++ b/node_modules/css-tree/cjs/generator/token-before.cjs @@ -0,0 +1,170 @@ +'use strict'; + +const types = require('../tokenizer/types.cjs'); + +const PLUSSIGN = 0x002B; // U+002B PLUS SIGN (+) +const HYPHENMINUS = 0x002D; // U+002D HYPHEN-MINUS (-) + +const code = (type, value) => { + if (type === types.Delim) { + type = value; + } + + if (typeof type === 'string') { + const charCode = type.charCodeAt(0); + return charCode > 0x7F ? 0x8000 : charCode << 8; + } + + return type; +}; + +// https://www.w3.org/TR/css-syntax-3/#serialization +// The only requirement for serialization is that it must "round-trip" with parsing, +// that is, parsing the stylesheet must produce the same data structures as parsing, +// serializing, and parsing again, except for consecutive s, +// which may be collapsed into a single token. + +const specPairs = [ + [types.Ident, types.Ident], + [types.Ident, types.Function], + [types.Ident, types.Url], + [types.Ident, types.BadUrl], + [types.Ident, '-'], + [types.Ident, types.Number], + [types.Ident, types.Percentage], + [types.Ident, types.Dimension], + [types.Ident, types.CDC], + [types.Ident, types.LeftParenthesis], + + [types.AtKeyword, types.Ident], + [types.AtKeyword, types.Function], + [types.AtKeyword, types.Url], + [types.AtKeyword, types.BadUrl], + [types.AtKeyword, '-'], + [types.AtKeyword, types.Number], + [types.AtKeyword, types.Percentage], + [types.AtKeyword, types.Dimension], + [types.AtKeyword, types.CDC], + + [types.Hash, types.Ident], + [types.Hash, types.Function], + [types.Hash, types.Url], + [types.Hash, types.BadUrl], + [types.Hash, '-'], + [types.Hash, types.Number], + [types.Hash, types.Percentage], + [types.Hash, types.Dimension], + [types.Hash, types.CDC], + + [types.Dimension, types.Ident], + [types.Dimension, types.Function], + [types.Dimension, types.Url], + [types.Dimension, types.BadUrl], + [types.Dimension, '-'], + [types.Dimension, types.Number], + [types.Dimension, types.Percentage], + [types.Dimension, types.Dimension], + [types.Dimension, types.CDC], + + ['#', types.Ident], + ['#', types.Function], + ['#', types.Url], + ['#', types.BadUrl], + ['#', '-'], + ['#', types.Number], + ['#', types.Percentage], + ['#', types.Dimension], + ['#', types.CDC], // https://github.com/w3c/csswg-drafts/pull/6874 + + ['-', types.Ident], + ['-', types.Function], + ['-', types.Url], + ['-', types.BadUrl], + ['-', '-'], + ['-', types.Number], + ['-', types.Percentage], + ['-', types.Dimension], + ['-', types.CDC], // https://github.com/w3c/csswg-drafts/pull/6874 + + [types.Number, types.Ident], + [types.Number, types.Function], + [types.Number, types.Url], + [types.Number, types.BadUrl], + [types.Number, types.Number], + [types.Number, types.Percentage], + [types.Number, types.Dimension], + [types.Number, '%'], + [types.Number, types.CDC], // https://github.com/w3c/csswg-drafts/pull/6874 + + ['@', types.Ident], + ['@', types.Function], + ['@', types.Url], + ['@', types.BadUrl], + ['@', '-'], + ['@', types.CDC], // https://github.com/w3c/csswg-drafts/pull/6874 + + ['.', types.Number], + ['.', types.Percentage], + ['.', types.Dimension], + + ['+', types.Number], + ['+', types.Percentage], + ['+', types.Dimension], + + ['/', '*'] +]; +// validate with scripts/generate-safe +const safePairs = specPairs.concat([ + [types.Ident, types.Hash], + + [types.Dimension, types.Hash], + + [types.Hash, types.Hash], + + [types.AtKeyword, types.LeftParenthesis], + [types.AtKeyword, types.String], + [types.AtKeyword, types.Colon], + + [types.Percentage, types.Percentage], + [types.Percentage, types.Dimension], + [types.Percentage, types.Function], + [types.Percentage, '-'], + + [types.RightParenthesis, types.Ident], + [types.RightParenthesis, types.Function], + [types.RightParenthesis, types.Percentage], + [types.RightParenthesis, types.Dimension], + [types.RightParenthesis, types.Hash], + [types.RightParenthesis, '-'] +]); + +function createMap(pairs) { + const isWhiteSpaceRequired = new Set( + pairs.map(([prev, next]) => (code(prev) << 16 | code(next))) + ); + + return function(prevCode, type, value) { + const nextCode = code(type, value); + const nextCharCode = value.charCodeAt(0); + const emitWs = + (nextCharCode === HYPHENMINUS && + type !== types.Ident && + type !== types.Function && + type !== types.CDC) || + (nextCharCode === PLUSSIGN) + ? isWhiteSpaceRequired.has(prevCode << 16 | nextCharCode << 8) + : isWhiteSpaceRequired.has(prevCode << 16 | nextCode); + + if (emitWs) { + this.emit(' ', types.WhiteSpace, true); + } + + return nextCode; + }; +} + +const spec = createMap(specPairs); +const safe = createMap(safePairs); + +exports.safe = safe; +exports.spec = spec; diff --git a/node_modules/css-tree/cjs/index.cjs b/node_modules/css-tree/cjs/index.cjs new file mode 100755 index 000000000..cc6113787 --- /dev/null +++ b/node_modules/css-tree/cjs/index.cjs @@ -0,0 +1,65 @@ +'use strict'; + +const index$1 = require('./syntax/index.cjs'); +const version = require('./version.cjs'); +const create = require('./syntax/create.cjs'); +const List = require('./utils/List.cjs'); +const Lexer = require('./lexer/Lexer.cjs'); +const index = require('./definition-syntax/index.cjs'); +const clone = require('./utils/clone.cjs'); +const names$1 = require('./utils/names.cjs'); +const ident = require('./utils/ident.cjs'); +const string = require('./utils/string.cjs'); +const url = require('./utils/url.cjs'); +const types = require('./tokenizer/types.cjs'); +const names = require('./tokenizer/names.cjs'); +const TokenStream = require('./tokenizer/TokenStream.cjs'); +const OffsetToLocation = require('./tokenizer/OffsetToLocation.cjs'); + +const { + tokenize, + parse, + generate, + lexer, + createLexer, + + walk, + find, + findLast, + findAll, + + toPlainObject, + fromPlainObject, + + fork +} = index$1; + +exports.version = version.version; +exports.createSyntax = create; +exports.List = List.List; +exports.Lexer = Lexer.Lexer; +exports.definitionSyntax = index; +exports.clone = clone.clone; +exports.isCustomProperty = names$1.isCustomProperty; +exports.keyword = names$1.keyword; +exports.property = names$1.property; +exports.vendorPrefix = names$1.vendorPrefix; +exports.ident = ident; +exports.string = string; +exports.url = url; +exports.tokenTypes = types; +exports.tokenNames = names; +exports.TokenStream = TokenStream.TokenStream; +exports.OffsetToLocation = OffsetToLocation.OffsetToLocation; +exports.createLexer = createLexer; +exports.find = find; +exports.findAll = findAll; +exports.findLast = findLast; +exports.fork = fork; +exports.fromPlainObject = fromPlainObject; +exports.generate = generate; +exports.lexer = lexer; +exports.parse = parse; +exports.toPlainObject = toPlainObject; +exports.tokenize = tokenize; +exports.walk = walk; diff --git a/node_modules/css-tree/cjs/lexer/Lexer.cjs b/node_modules/css-tree/cjs/lexer/Lexer.cjs new file mode 100644 index 000000000..a6d1fcb66 --- /dev/null +++ b/node_modules/css-tree/cjs/lexer/Lexer.cjs @@ -0,0 +1,517 @@ +'use strict'; + +const error = require('./error.cjs'); +const names = require('../utils/names.cjs'); +const genericConst = require('./generic-const.cjs'); +const generic = require('./generic.cjs'); +const units = require('./units.cjs'); +const prepareTokens = require('./prepare-tokens.cjs'); +const matchGraph = require('./match-graph.cjs'); +const match = require('./match.cjs'); +const trace = require('./trace.cjs'); +const search = require('./search.cjs'); +const structure = require('./structure.cjs'); +const parse = require('../definition-syntax/parse.cjs'); +const generate = require('../definition-syntax/generate.cjs'); +const walk = require('../definition-syntax/walk.cjs'); + +function dumpMapSyntax(map, compact, syntaxAsAst) { + const result = {}; + + for (const name in map) { + if (map[name].syntax) { + result[name] = syntaxAsAst + ? map[name].syntax + : generate.generate(map[name].syntax, { compact }); + } + } + + return result; +} + +function dumpAtruleMapSyntax(map, compact, syntaxAsAst) { + const result = {}; + + for (const [name, atrule] of Object.entries(map)) { + result[name] = { + prelude: atrule.prelude && ( + syntaxAsAst + ? atrule.prelude.syntax + : generate.generate(atrule.prelude.syntax, { compact }) + ), + descriptors: atrule.descriptors && dumpMapSyntax(atrule.descriptors, compact, syntaxAsAst) + }; + } + + return result; +} + +function valueHasVar(tokens) { + for (let i = 0; i < tokens.length; i++) { + if (tokens[i].value.toLowerCase() === 'var(') { + return true; + } + } + + return false; +} + +function syntaxHasTopLevelCommaMultiplier(syntax) { + const singleTerm = syntax.terms[0]; + + return ( + syntax.explicit === false && + syntax.terms.length === 1 && + singleTerm.type === 'Multiplier' && + singleTerm.comma === true + ); +} + +function buildMatchResult(matched, error, iterations) { + return { + matched, + iterations, + error, + ...trace + }; +} + +function matchSyntax(lexer, syntax, value, useCssWideKeywords) { + const tokens = prepareTokens(value, lexer.syntax); + let result; + + if (valueHasVar(tokens)) { + return buildMatchResult(null, new Error('Matching for a tree with var() is not supported')); + } + + if (useCssWideKeywords) { + result = match.matchAsTree(tokens, lexer.cssWideKeywordsSyntax, lexer); + } + + if (!useCssWideKeywords || !result.match) { + result = match.matchAsTree(tokens, syntax.match, lexer); + if (!result.match) { + return buildMatchResult( + null, + new error.SyntaxMatchError(result.reason, syntax.syntax, value, result), + result.iterations + ); + } + } + + return buildMatchResult(result.match, null, result.iterations); +} + +class Lexer { + constructor(config, syntax, structure$1) { + this.cssWideKeywords = genericConst.cssWideKeywords; + this.syntax = syntax; + this.generic = false; + this.units = { ...units }; + this.atrules = Object.create(null); + this.properties = Object.create(null); + this.types = Object.create(null); + this.structure = structure$1 || structure.getStructureFromConfig(config); + + if (config) { + if (config.cssWideKeywords) { + this.cssWideKeywords = config.cssWideKeywords; + } + + if (config.units) { + for (const group of Object.keys(units)) { + if (Array.isArray(config.units[group])) { + this.units[group] = config.units[group]; + } + } + } + + if (config.types) { + for (const [name, type] of Object.entries(config.types)) { + this.addType_(name, type); + } + } + + if (config.generic) { + this.generic = true; + for (const [name, value] of Object.entries(generic.createGenericTypes(this.units))) { + this.addType_(name, value); + } + } + + if (config.atrules) { + for (const [name, atrule] of Object.entries(config.atrules)) { + this.addAtrule_(name, atrule); + } + } + + if (config.properties) { + for (const [name, property] of Object.entries(config.properties)) { + this.addProperty_(name, property); + } + } + } + + this.cssWideKeywordsSyntax = matchGraph.buildMatchGraph(this.cssWideKeywords.join(' | ')); + } + + checkStructure(ast) { + function collectWarning(node, message) { + warns.push({ node, message }); + } + + const structure = this.structure; + const warns = []; + + this.syntax.walk(ast, function(node) { + if (structure.hasOwnProperty(node.type)) { + structure[node.type].check(node, collectWarning); + } else { + collectWarning(node, 'Unknown node type `' + node.type + '`'); + } + }); + + return warns.length ? warns : false; + } + + createDescriptor(syntax, type, name, parent = null) { + const ref = { + type, + name + }; + const descriptor = { + type, + name, + parent, + serializable: typeof syntax === 'string' || (syntax && typeof syntax.type === 'string'), + syntax: null, + match: null, + matchRef: null // used for properties when a syntax referenced as <'property'> in other syntax definitions + }; + + if (typeof syntax === 'function') { + descriptor.match = matchGraph.buildMatchGraph(syntax, ref); + } else { + if (typeof syntax === 'string') { + // lazy parsing on first access + Object.defineProperty(descriptor, 'syntax', { + get() { + Object.defineProperty(descriptor, 'syntax', { + value: parse.parse(syntax) + }); + + return descriptor.syntax; + } + }); + } else { + descriptor.syntax = syntax; + } + + // lazy graph build on first access + Object.defineProperty(descriptor, 'match', { + get() { + Object.defineProperty(descriptor, 'match', { + value: matchGraph.buildMatchGraph(descriptor.syntax, ref) + }); + + return descriptor.match; + } + }); + + if (type === 'Property') { + Object.defineProperty(descriptor, 'matchRef', { + get() { + const syntax = descriptor.syntax; + const value = syntaxHasTopLevelCommaMultiplier(syntax) + ? matchGraph.buildMatchGraph({ + ...syntax, + terms: [syntax.terms[0].term] + }, ref) + : null; + + Object.defineProperty(descriptor, 'matchRef', { + value + }); + + return value; + } + }); + } + } + + return descriptor; + } + addAtrule_(name, syntax) { + if (!syntax) { + return; + } + + this.atrules[name] = { + type: 'Atrule', + name: name, + prelude: syntax.prelude ? this.createDescriptor(syntax.prelude, 'AtrulePrelude', name) : null, + descriptors: syntax.descriptors + ? Object.keys(syntax.descriptors).reduce( + (map, descName) => { + map[descName] = this.createDescriptor(syntax.descriptors[descName], 'AtruleDescriptor', descName, name); + return map; + }, + Object.create(null) + ) + : null + }; + } + addProperty_(name, syntax) { + if (!syntax) { + return; + } + + this.properties[name] = this.createDescriptor(syntax, 'Property', name); + } + addType_(name, syntax) { + if (!syntax) { + return; + } + + this.types[name] = this.createDescriptor(syntax, 'Type', name); + } + + checkAtruleName(atruleName) { + if (!this.getAtrule(atruleName)) { + return new error.SyntaxReferenceError('Unknown at-rule', '@' + atruleName); + } + } + checkAtrulePrelude(atruleName, prelude) { + const error = this.checkAtruleName(atruleName); + + if (error) { + return error; + } + + const atrule = this.getAtrule(atruleName); + + if (!atrule.prelude && prelude) { + return new SyntaxError('At-rule `@' + atruleName + '` should not contain a prelude'); + } + + if (atrule.prelude && !prelude) { + if (!matchSyntax(this, atrule.prelude, '', false).matched) { + return new SyntaxError('At-rule `@' + atruleName + '` should contain a prelude'); + } + } + } + checkAtruleDescriptorName(atruleName, descriptorName) { + const error$1 = this.checkAtruleName(atruleName); + + if (error$1) { + return error$1; + } + + const atrule = this.getAtrule(atruleName); + const descriptor = names.keyword(descriptorName); + + if (!atrule.descriptors) { + return new SyntaxError('At-rule `@' + atruleName + '` has no known descriptors'); + } + + if (!atrule.descriptors[descriptor.name] && + !atrule.descriptors[descriptor.basename]) { + return new error.SyntaxReferenceError('Unknown at-rule descriptor', descriptorName); + } + } + checkPropertyName(propertyName) { + if (!this.getProperty(propertyName)) { + return new error.SyntaxReferenceError('Unknown property', propertyName); + } + } + + matchAtrulePrelude(atruleName, prelude) { + const error = this.checkAtrulePrelude(atruleName, prelude); + + if (error) { + return buildMatchResult(null, error); + } + + const atrule = this.getAtrule(atruleName); + + if (!atrule.prelude) { + return buildMatchResult(null, null); + } + + return matchSyntax(this, atrule.prelude, prelude || '', false); + } + matchAtruleDescriptor(atruleName, descriptorName, value) { + const error = this.checkAtruleDescriptorName(atruleName, descriptorName); + + if (error) { + return buildMatchResult(null, error); + } + + const atrule = this.getAtrule(atruleName); + const descriptor = names.keyword(descriptorName); + + return matchSyntax(this, atrule.descriptors[descriptor.name] || atrule.descriptors[descriptor.basename], value, false); + } + matchDeclaration(node) { + if (node.type !== 'Declaration') { + return buildMatchResult(null, new Error('Not a Declaration node')); + } + + return this.matchProperty(node.property, node.value); + } + matchProperty(propertyName, value) { + // don't match syntax for a custom property at the moment + if (names.property(propertyName).custom) { + return buildMatchResult(null, new Error('Lexer matching doesn\'t applicable for custom properties')); + } + + const error = this.checkPropertyName(propertyName); + + if (error) { + return buildMatchResult(null, error); + } + + return matchSyntax(this, this.getProperty(propertyName), value, true); + } + matchType(typeName, value) { + const typeSyntax = this.getType(typeName); + + if (!typeSyntax) { + return buildMatchResult(null, new error.SyntaxReferenceError('Unknown type', typeName)); + } + + return matchSyntax(this, typeSyntax, value, false); + } + match(syntax, value) { + if (typeof syntax !== 'string' && (!syntax || !syntax.type)) { + return buildMatchResult(null, new error.SyntaxReferenceError('Bad syntax')); + } + + if (typeof syntax === 'string' || !syntax.match) { + syntax = this.createDescriptor(syntax, 'Type', 'anonymous'); + } + + return matchSyntax(this, syntax, value, false); + } + + findValueFragments(propertyName, value, type, name) { + return search.matchFragments(this, value, this.matchProperty(propertyName, value), type, name); + } + findDeclarationValueFragments(declaration, type, name) { + return search.matchFragments(this, declaration.value, this.matchDeclaration(declaration), type, name); + } + findAllFragments(ast, type, name) { + const result = []; + + this.syntax.walk(ast, { + visit: 'Declaration', + enter: (declaration) => { + result.push.apply(result, this.findDeclarationValueFragments(declaration, type, name)); + } + }); + + return result; + } + + getAtrule(atruleName, fallbackBasename = true) { + const atrule = names.keyword(atruleName); + const atruleEntry = atrule.vendor && fallbackBasename + ? this.atrules[atrule.name] || this.atrules[atrule.basename] + : this.atrules[atrule.name]; + + return atruleEntry || null; + } + getAtrulePrelude(atruleName, fallbackBasename = true) { + const atrule = this.getAtrule(atruleName, fallbackBasename); + + return atrule && atrule.prelude || null; + } + getAtruleDescriptor(atruleName, name) { + return this.atrules.hasOwnProperty(atruleName) && this.atrules.declarators + ? this.atrules[atruleName].declarators[name] || null + : null; + } + getProperty(propertyName, fallbackBasename = true) { + const property = names.property(propertyName); + const propertyEntry = property.vendor && fallbackBasename + ? this.properties[property.name] || this.properties[property.basename] + : this.properties[property.name]; + + return propertyEntry || null; + } + getType(name) { + return hasOwnProperty.call(this.types, name) ? this.types[name] : null; + } + + validate() { + function syntaxRef(name, isType) { + return isType ? `<${name}>` : `<'${name}'>`; + } + + function validate(syntax, name, broken, descriptor) { + if (broken.has(name)) { + return broken.get(name); + } + + broken.set(name, false); + if (descriptor.syntax !== null) { + walk.walk(descriptor.syntax, function(node) { + if (node.type !== 'Type' && node.type !== 'Property') { + return; + } + + const map = node.type === 'Type' ? syntax.types : syntax.properties; + const brokenMap = node.type === 'Type' ? brokenTypes : brokenProperties; + + if (!hasOwnProperty.call(map, node.name)) { + errors.push(`${syntaxRef(name, broken === brokenTypes)} used missed syntax definition ${syntaxRef(node.name, node.type === 'Type')}`); + broken.set(name, true); + } else if (validate(syntax, node.name, brokenMap, map[node.name])) { + errors.push(`${syntaxRef(name, broken === brokenTypes)} used broken syntax definition ${syntaxRef(node.name, node.type === 'Type')}`); + broken.set(name, true); + } + }, this); + } + } + + const errors = []; + let brokenTypes = new Map(); + let brokenProperties = new Map(); + + for (const key in this.types) { + validate(this, key, brokenTypes, this.types[key]); + } + + for (const key in this.properties) { + validate(this, key, brokenProperties, this.properties[key]); + } + + const brokenTypesArray = [...brokenTypes.keys()].filter(name => brokenTypes.get(name)); + const brokenPropertiesArray = [...brokenProperties.keys()].filter(name => brokenProperties.get(name)); + + if (brokenTypesArray.length || brokenPropertiesArray.length) { + return { + errors, + types: brokenTypesArray, + properties: brokenPropertiesArray + }; + } + + return null; + } + dump(syntaxAsAst, pretty) { + return { + generic: this.generic, + cssWideKeywords: this.cssWideKeywords, + units: this.units, + types: dumpMapSyntax(this.types, !pretty, syntaxAsAst), + properties: dumpMapSyntax(this.properties, !pretty, syntaxAsAst), + atrules: dumpAtruleMapSyntax(this.atrules, !pretty, syntaxAsAst) + }; + } + toString() { + return JSON.stringify(this.dump()); + } +} + +exports.Lexer = Lexer; diff --git a/node_modules/css-tree/cjs/lexer/error.cjs b/node_modules/css-tree/cjs/lexer/error.cjs new file mode 100644 index 000000000..8d252eeb5 --- /dev/null +++ b/node_modules/css-tree/cjs/lexer/error.cjs @@ -0,0 +1,128 @@ +'use strict'; + +const createCustomError = require('../utils/create-custom-error.cjs'); +const generate = require('../definition-syntax/generate.cjs'); + +const defaultLoc = { offset: 0, line: 1, column: 1 }; + +function locateMismatch(matchResult, node) { + const tokens = matchResult.tokens; + const longestMatch = matchResult.longestMatch; + const mismatchNode = longestMatch < tokens.length ? tokens[longestMatch].node || null : null; + const badNode = mismatchNode !== node ? mismatchNode : null; + let mismatchOffset = 0; + let mismatchLength = 0; + let entries = 0; + let css = ''; + let start; + let end; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i].value; + + if (i === longestMatch) { + mismatchLength = token.length; + mismatchOffset = css.length; + } + + if (badNode !== null && tokens[i].node === badNode) { + if (i <= longestMatch) { + entries++; + } else { + entries = 0; + } + } + + css += token; + } + + if (longestMatch === tokens.length || entries > 1) { // last + start = fromLoc(badNode || node, 'end') || buildLoc(defaultLoc, css); + end = buildLoc(start); + } else { + start = fromLoc(badNode, 'start') || + buildLoc(fromLoc(node, 'start') || defaultLoc, css.slice(0, mismatchOffset)); + end = fromLoc(badNode, 'end') || + buildLoc(start, css.substr(mismatchOffset, mismatchLength)); + } + + return { + css, + mismatchOffset, + mismatchLength, + start, + end + }; +} + +function fromLoc(node, point) { + const value = node && node.loc && node.loc[point]; + + if (value) { + return 'line' in value ? buildLoc(value) : value; + } + + return null; +} + +function buildLoc({ offset, line, column }, extra) { + const loc = { + offset, + line, + column + }; + + if (extra) { + const lines = extra.split(/\n|\r\n?|\f/); + + loc.offset += extra.length; + loc.line += lines.length - 1; + loc.column = lines.length === 1 ? loc.column + extra.length : lines.pop().length + 1; + } + + return loc; +} + +const SyntaxReferenceError = function(type, referenceName) { + const error = createCustomError.createCustomError( + 'SyntaxReferenceError', + type + (referenceName ? ' `' + referenceName + '`' : '') + ); + + error.reference = referenceName; + + return error; +}; + +const SyntaxMatchError = function(message, syntax, node, matchResult) { + const error = createCustomError.createCustomError('SyntaxMatchError', message); + const { + css, + mismatchOffset, + mismatchLength, + start, + end + } = locateMismatch(matchResult, node); + + error.rawMessage = message; + error.syntax = syntax ? generate.generate(syntax) : ''; + error.css = css; + error.mismatchOffset = mismatchOffset; + error.mismatchLength = mismatchLength; + error.message = message + '\n' + + ' syntax: ' + error.syntax + '\n' + + ' value: ' + (css || '') + '\n' + + ' --------' + new Array(error.mismatchOffset + 1).join('-') + '^'; + + Object.assign(error, start); + error.loc = { + source: (node && node.loc && node.loc.source) || '', + start, + end + }; + + return error; +}; + +exports.SyntaxMatchError = SyntaxMatchError; +exports.SyntaxReferenceError = SyntaxReferenceError; diff --git a/node_modules/css-tree/cjs/lexer/generic-an-plus-b.cjs b/node_modules/css-tree/cjs/lexer/generic-an-plus-b.cjs new file mode 100644 index 000000000..a5dfba3e2 --- /dev/null +++ b/node_modules/css-tree/cjs/lexer/generic-an-plus-b.cjs @@ -0,0 +1,235 @@ +'use strict'; + +const charCodeDefinitions = require('../tokenizer/char-code-definitions.cjs'); +const types = require('../tokenizer/types.cjs'); +const utils = require('../tokenizer/utils.cjs'); + +const PLUSSIGN = 0x002B; // U+002B PLUS SIGN (+) +const HYPHENMINUS = 0x002D; // U+002D HYPHEN-MINUS (-) +const N = 0x006E; // U+006E LATIN SMALL LETTER N (n) +const DISALLOW_SIGN = true; +const ALLOW_SIGN = false; + +function isDelim(token, code) { + return token !== null && token.type === types.Delim && token.value.charCodeAt(0) === code; +} + +function skipSC(token, offset, getNextToken) { + while (token !== null && (token.type === types.WhiteSpace || token.type === types.Comment)) { + token = getNextToken(++offset); + } + + return offset; +} + +function checkInteger(token, valueOffset, disallowSign, offset) { + if (!token) { + return 0; + } + + const code = token.value.charCodeAt(valueOffset); + + if (code === PLUSSIGN || code === HYPHENMINUS) { + if (disallowSign) { + // Number sign is not allowed + return 0; + } + valueOffset++; + } + + for (; valueOffset < token.value.length; valueOffset++) { + if (!charCodeDefinitions.isDigit(token.value.charCodeAt(valueOffset))) { + // Integer is expected + return 0; + } + } + + return offset + 1; +} + +// ... +// ... ['+' | '-'] +function consumeB(token, offset_, getNextToken) { + let sign = false; + let offset = skipSC(token, offset_, getNextToken); + + token = getNextToken(offset); + + if (token === null) { + return offset_; + } + + if (token.type !== types.Number) { + if (isDelim(token, PLUSSIGN) || isDelim(token, HYPHENMINUS)) { + sign = true; + offset = skipSC(getNextToken(++offset), offset, getNextToken); + token = getNextToken(offset); + + if (token === null || token.type !== types.Number) { + return 0; + } + } else { + return offset_; + } + } + + if (!sign) { + const code = token.value.charCodeAt(0); + if (code !== PLUSSIGN && code !== HYPHENMINUS) { + // Number sign is expected + return 0; + } + } + + return checkInteger(token, sign ? 0 : 1, sign, offset); +} + +// An+B microsyntax https://www.w3.org/TR/css-syntax-3/#anb +function anPlusB(token, getNextToken) { + /* eslint-disable brace-style*/ + let offset = 0; + + if (!token) { + return 0; + } + + // + if (token.type === types.Number) { + return checkInteger(token, 0, ALLOW_SIGN, offset); // b + } + + // -n + // -n + // -n ['+' | '-'] + // -n- + // + else if (token.type === types.Ident && token.value.charCodeAt(0) === HYPHENMINUS) { + // expect 1st char is N + if (!utils.cmpChar(token.value, 1, N)) { + return 0; + } + + switch (token.value.length) { + // -n + // -n + // -n ['+' | '-'] + case 2: + return consumeB(getNextToken(++offset), offset, getNextToken); + + // -n- + case 3: + if (token.value.charCodeAt(2) !== HYPHENMINUS) { + return 0; + } + + offset = skipSC(getNextToken(++offset), offset, getNextToken); + token = getNextToken(offset); + + return checkInteger(token, 0, DISALLOW_SIGN, offset); + + // + default: + if (token.value.charCodeAt(2) !== HYPHENMINUS) { + return 0; + } + + return checkInteger(token, 3, DISALLOW_SIGN, offset); + } + } + + // '+'? n + // '+'? n + // '+'? n ['+' | '-'] + // '+'? n- + // '+'? + else if (token.type === types.Ident || (isDelim(token, PLUSSIGN) && getNextToken(offset + 1).type === types.Ident)) { + // just ignore a plus + if (token.type !== types.Ident) { + token = getNextToken(++offset); + } + + if (token === null || !utils.cmpChar(token.value, 0, N)) { + return 0; + } + + switch (token.value.length) { + // '+'? n + // '+'? n + // '+'? n ['+' | '-'] + case 1: + return consumeB(getNextToken(++offset), offset, getNextToken); + + // '+'? n- + case 2: + if (token.value.charCodeAt(1) !== HYPHENMINUS) { + return 0; + } + + offset = skipSC(getNextToken(++offset), offset, getNextToken); + token = getNextToken(offset); + + return checkInteger(token, 0, DISALLOW_SIGN, offset); + + // '+'? + default: + if (token.value.charCodeAt(1) !== HYPHENMINUS) { + return 0; + } + + return checkInteger(token, 2, DISALLOW_SIGN, offset); + } + } + + // + // + // + // + // ['+' | '-'] + else if (token.type === types.Dimension) { + let code = token.value.charCodeAt(0); + let sign = code === PLUSSIGN || code === HYPHENMINUS ? 1 : 0; + let i = sign; + + for (; i < token.value.length; i++) { + if (!charCodeDefinitions.isDigit(token.value.charCodeAt(i))) { + break; + } + } + + if (i === sign) { + // Integer is expected + return 0; + } + + if (!utils.cmpChar(token.value, i, N)) { + return 0; + } + + // + // + // ['+' | '-'] + if (i + 1 === token.value.length) { + return consumeB(getNextToken(++offset), offset, getNextToken); + } else { + if (token.value.charCodeAt(i + 1) !== HYPHENMINUS) { + return 0; + } + + // + if (i + 2 === token.value.length) { + offset = skipSC(getNextToken(++offset), offset, getNextToken); + token = getNextToken(offset); + + return checkInteger(token, 0, DISALLOW_SIGN, offset); + } + // + else { + return checkInteger(token, i + 2, DISALLOW_SIGN, offset); + } + } + } + + return 0; +} + +module.exports = anPlusB; diff --git a/node_modules/css-tree/cjs/lexer/generic-const.cjs b/node_modules/css-tree/cjs/lexer/generic-const.cjs new file mode 100644 index 000000000..9b9f6157a --- /dev/null +++ b/node_modules/css-tree/cjs/lexer/generic-const.cjs @@ -0,0 +1,12 @@ +'use strict'; + +// https://drafts.csswg.org/css-cascade-5/ +const cssWideKeywords = [ + 'initial', + 'inherit', + 'unset', + 'revert', + 'revert-layer' +]; + +exports.cssWideKeywords = cssWideKeywords; diff --git a/node_modules/css-tree/cjs/lexer/generic-urange.cjs b/node_modules/css-tree/cjs/lexer/generic-urange.cjs new file mode 100644 index 000000000..ce167bb12 --- /dev/null +++ b/node_modules/css-tree/cjs/lexer/generic-urange.cjs @@ -0,0 +1,149 @@ +'use strict'; + +const charCodeDefinitions = require('../tokenizer/char-code-definitions.cjs'); +const types = require('../tokenizer/types.cjs'); +const utils = require('../tokenizer/utils.cjs'); + +const PLUSSIGN = 0x002B; // U+002B PLUS SIGN (+) +const HYPHENMINUS = 0x002D; // U+002D HYPHEN-MINUS (-) +const QUESTIONMARK = 0x003F; // U+003F QUESTION MARK (?) +const U = 0x0075; // U+0075 LATIN SMALL LETTER U (u) + +function isDelim(token, code) { + return token !== null && token.type === types.Delim && token.value.charCodeAt(0) === code; +} + +function startsWith(token, code) { + return token.value.charCodeAt(0) === code; +} + +function hexSequence(token, offset, allowDash) { + let hexlen = 0; + + for (let pos = offset; pos < token.value.length; pos++) { + const code = token.value.charCodeAt(pos); + + if (code === HYPHENMINUS && allowDash && hexlen !== 0) { + hexSequence(token, offset + hexlen + 1, false); + return 6; // dissallow following question marks + } + + if (!charCodeDefinitions.isHexDigit(code)) { + return 0; // not a hex digit + } + + if (++hexlen > 6) { + return 0; // too many hex digits + } } + + return hexlen; +} + +function withQuestionMarkSequence(consumed, length, getNextToken) { + if (!consumed) { + return 0; // nothing consumed + } + + while (isDelim(getNextToken(length), QUESTIONMARK)) { + if (++consumed > 6) { + return 0; // too many question marks + } + + length++; + } + + return length; +} + +// https://drafts.csswg.org/css-syntax/#urange +// Informally, the production has three forms: +// U+0001 +// Defines a range consisting of a single code point, in this case the code point "1". +// U+0001-00ff +// Defines a range of codepoints between the first and the second value, in this case +// the range between "1" and "ff" (255 in decimal) inclusive. +// U+00?? +// Defines a range of codepoints where the "?" characters range over all hex digits, +// in this case defining the same as the value U+0000-00ff. +// In each form, a maximum of 6 digits is allowed for each hexadecimal number (if you treat "?" as a hexadecimal digit). +// +// = +// u '+' '?'* | +// u '?'* | +// u '?'* | +// u | +// u | +// u '+' '?'+ +function urange(token, getNextToken) { + let length = 0; + + // should start with `u` or `U` + if (token === null || token.type !== types.Ident || !utils.cmpChar(token.value, 0, U)) { + return 0; + } + + token = getNextToken(++length); + if (token === null) { + return 0; + } + + // u '+' '?'* + // u '+' '?'+ + if (isDelim(token, PLUSSIGN)) { + token = getNextToken(++length); + if (token === null) { + return 0; + } + + if (token.type === types.Ident) { + // u '+' '?'* + return withQuestionMarkSequence(hexSequence(token, 0, true), ++length, getNextToken); + } + + if (isDelim(token, QUESTIONMARK)) { + // u '+' '?'+ + return withQuestionMarkSequence(1, ++length, getNextToken); + } + + // Hex digit or question mark is expected + return 0; + } + + // u '?'* + // u + // u + if (token.type === types.Number) { + const consumedHexLength = hexSequence(token, 1, true); + if (consumedHexLength === 0) { + return 0; + } + + token = getNextToken(++length); + if (token === null) { + // u + return length; + } + + if (token.type === types.Dimension || token.type === types.Number) { + // u + // u + if (!startsWith(token, HYPHENMINUS) || !hexSequence(token, 1, false)) { + return 0; + } + + return length + 1; + } + + // u '?'* + return withQuestionMarkSequence(consumedHexLength, length, getNextToken); + } + + // u '?'* + if (token.type === types.Dimension) { + return withQuestionMarkSequence(hexSequence(token, 1, true), ++length, getNextToken); + } + + return 0; +} + +module.exports = urange; diff --git a/node_modules/css-tree/cjs/lexer/generic.cjs b/node_modules/css-tree/cjs/lexer/generic.cjs new file mode 100644 index 000000000..848991131 --- /dev/null +++ b/node_modules/css-tree/cjs/lexer/generic.cjs @@ -0,0 +1,589 @@ +'use strict'; + +const genericConst = require('./generic-const.cjs'); +const genericAnPlusB = require('./generic-an-plus-b.cjs'); +const genericUrange = require('./generic-urange.cjs'); +const charCodeDefinitions = require('../tokenizer/char-code-definitions.cjs'); +const types = require('../tokenizer/types.cjs'); +const utils = require('../tokenizer/utils.cjs'); + +const calcFunctionNames = ['calc(', '-moz-calc(', '-webkit-calc(']; +const balancePair = new Map([ + [types.Function, types.RightParenthesis], + [types.LeftParenthesis, types.RightParenthesis], + [types.LeftSquareBracket, types.RightSquareBracket], + [types.LeftCurlyBracket, types.RightCurlyBracket] +]); + +// safe char code getter +function charCodeAt(str, index) { + return index < str.length ? str.charCodeAt(index) : 0; +} + +function eqStr(actual, expected) { + return utils.cmpStr(actual, 0, actual.length, expected); +} + +function eqStrAny(actual, expected) { + for (let i = 0; i < expected.length; i++) { + if (eqStr(actual, expected[i])) { + return true; + } + } + + return false; +} + +// IE postfix hack, i.e. 123\0 or 123px\9 +function isPostfixIeHack(str, offset) { + if (offset !== str.length - 2) { + return false; + } + + return ( + charCodeAt(str, offset) === 0x005C && // U+005C REVERSE SOLIDUS (\) + charCodeDefinitions.isDigit(charCodeAt(str, offset + 1)) + ); +} + +function outOfRange(opts, value, numEnd) { + if (opts && opts.type === 'Range') { + const num = Number( + numEnd !== undefined && numEnd !== value.length + ? value.substr(0, numEnd) + : value + ); + + if (isNaN(num)) { + return true; + } + + // FIXME: when opts.min is a string it's a dimension, skip a range validation + // for now since it requires a type covertation which is not implmented yet + if (opts.min !== null && num < opts.min && typeof opts.min !== 'string') { + return true; + } + + // FIXME: when opts.max is a string it's a dimension, skip a range validation + // for now since it requires a type covertation which is not implmented yet + if (opts.max !== null && num > opts.max && typeof opts.max !== 'string') { + return true; + } + } + + return false; +} + +function consumeFunction(token, getNextToken) { + let balanceCloseType = 0; + let balanceStash = []; + let length = 0; + + // balanced token consuming + scan: + do { + switch (token.type) { + case types.RightCurlyBracket: + case types.RightParenthesis: + case types.RightSquareBracket: + if (token.type !== balanceCloseType) { + break scan; + } + + balanceCloseType = balanceStash.pop(); + + if (balanceStash.length === 0) { + length++; + break scan; + } + + break; + + case types.Function: + case types.LeftParenthesis: + case types.LeftSquareBracket: + case types.LeftCurlyBracket: + balanceStash.push(balanceCloseType); + balanceCloseType = balancePair.get(token.type); + break; + } + + length++; + } while (token = getNextToken(length)); + + return length; +} + +// TODO: implement +// can be used wherever , , ,