dir() ) . self::FILE_NAME; } /** * Full URL to the generated stylesheet (cache-busted). * * @return string */ public function css_url() { $meta = get_option( self::OPTION_META, array() ); $ver = isset( $meta['mtime'] ) ? $meta['mtime'] : TOKENFORGE_VERSION; return add_query_arg( 'ver', $ver, trailingslashit( $this->url() ) . self::FILE_NAME ); } /** * Path of the stylesheet with every utility class (written only while * unused classes are left out of the main one; the builder canvas uses it). * * @return string */ public function full_css_path() { return trailingslashit( $this->dir() ) . self::FULL_FILE; } /** * URL of the stylesheet with every utility class (cache-busted). * * @return string */ public function full_css_url() { return add_query_arg( 'ver', $this->version(), trailingslashit( $this->url() ) . self::FULL_FILE ); } /** * Full path to the shop pages stylesheet (only written while WooCommerce's shop page styles are on). * * @return string */ public function shop_css_path() { return trailingslashit( $this->dir() ) . self::SHOP_FILE; } /** * URL of the shop pages stylesheet (cache-busted like the main one). * * @return string */ public function shop_css_url() { return add_query_arg( 'ver', $this->version(), trailingslashit( $this->url() ) . self::SHOP_FILE ); } /** * The shop pages stylesheet: '' when there is nothing to write. * * @return string */ public function build_shop_css() { $css = Woo::build_shop(); if ( '' === trim( $css ) ) { return ''; } return '/* Generated by TokenForge ' . TOKENFORGE_VERSION . ' on ' . gmdate( 'Y-m-d H:i:s' ) . " UTC: WooCommerce shop pages. Do not edit directly. */\n" . $css; } /** * Current stylesheet version (changes on every regenerate). * * @return string */ public function version() { $meta = get_option( self::OPTION_META, array() ); return isset( $meta['mtime'] ) ? (string) $meta['mtime'] : TOKENFORGE_VERSION; } /** * CSS for the block editor: the file, or a layer-free build when layers are on * (the editor's style scoping doesn't reliably handle @layer). * * @return string */ public function editor_css() { // The shop pages part too: Cart, Checkout and product blocks can be edited there. return $this->build_css( false, true ) . Woo::build_shop(); } /** * Are any cascade layers switched on? * * @return bool */ public function uses_layers() { $s = Settings::get_instance()->get_settings(); foreach ( self::LAYERS as $layer ) { if ( ! empty( $s[ 'layer_' . $layer ] ) ) { return true; } } return false; } /** Cascade layers, lowest priority first. */ const LAYERS = array( 'defaults', 'base', 'buttons', 'cards', 'icons', 'content' ); /** * Build the CSS text from enabled tokens. * * @param bool $allow_layers Wrap the opted-in low-priority parts in @layer * (false for the block editor, whose style * scoping doesn't reliably handle @layer). * @param bool $for_editor For the block editor (every utility class, no root font size). * @param bool $full Every utility class, even ones the site does not use. * @return string */ public function build_css( $allow_layers = true, $for_editor = false, $full = false ) { $tokens = Settings::get_instance()->all_variables(); $lines = array(); foreach ( $tokens as $t ) { if ( empty( $t['enabled'] ) || '' === $t['value'] ) { continue; } $lines[] = sprintf( "\t--%s: %s;", $t['name'], $t['value'] ); } $header = "/* Generated by TokenForge " . TOKENFORGE_VERSION . " on " . gmdate( 'Y-m-d H:i:s' ) . " UTC. Do not edit directly. */\n"; $body = ":root {\n" . implode( "\n", $lines ) . "\n}\n"; // Append the base-styles/components layer, then the utility-class layer // (utilities last so a class always wins over a base element style). $defaults = Defaults::build(); // reset first $components = Components::build( $for_editor ); $scheme = Scheme::build(); $extras = Extras::build(); $overlays = Overlays::build(); $motion = Motion::build( $for_editor ); $content = Content::build(); $width = Width::build(); $fw = Framework::build( $full || $for_editor ); $woo = Woo::build(); // '' unless WooCommerce is active // Site-wide blockquotes cascade with the base styles (same layer choice). $components['css'] .= isset( $content['base'] ) ? $content['base'] : ''; $parts = array( 'defaults' => $defaults['css'], 'base' => $components['css'], 'buttons' => isset( $components['buttons'] ) ? $components['buttons'] : '', 'cards' => isset( $components['cards'] ) ? $components['cards'] : '', 'icons' => isset( $extras['icons'] ) ? $extras['icons'] : '', 'content' => $content['css'], ); // Optional cascade layers: layered rules lose to ALL unlayered CSS (theme, // builder, custom), so these parts become trivially overridable. Tokens, // scheme, effects and utilities stay unlayered so they keep winning. $s = Settings::get_instance()->get_settings(); $order = ''; if ( $allow_layers && $this->uses_layers() ) { $order = '@layer ' . implode( ', ', array_map( function ( $l ) { return 'tokenforge.' . $l; }, self::LAYERS ) ) . ";\n"; foreach ( $parts as $layer => $css ) { if ( ! empty( $s[ 'layer_' . $layer ] ) && '' !== trim( $css ) ) { $parts[ $layer ] = "\n@layer tokenforge.$layer {" . $css . "}\n"; } } } // Root font size: unlayered (beats Bricks' @layer 62.5%); never in the // block editor, whose rem base is the editor's own. $root = $for_editor ? '' : Defaults::root_css(); // Developer SCSS (tokenforge_global_scss filter) last and unlayered, so it wins over everything generated. $scss = Scss::css(); $scss = '' !== $scss ? " /* --- Developer SCSS --- */ " . $scss . " " : ''; return $header . $order . Fonts::css() . ( isset( $defaults['reset'] ) ? $defaults['reset'] : '' ) . $parts['defaults'] . $body . $root . $parts['base'] . $parts['buttons'] . $parts['cards'] . $woo . $scheme['css'] . $extras['css'] . $parts['icons'] . Attributes::build( ! $full && ! $for_editor ) . $overlays['css'] . $motion['css'] . $width['css'] . $parts['content'] . $fw['css'] . $scss; } /** * Write the stylesheet, then let the builder sync pick up the change. * * @return bool True when the stylesheet was written. */ public function rebuild() { $ok = $this->generate(); do_action( 'tokenforge_tokens_saved' ); return $ok; } /** * Write a file so a visitor never reads half of it: write beside it, then rename over it. * A failure leaves the old file as it was. * * @param string $path Target file. * @param string $content Content. * @return bool True on success. */ private function write_file( $path, $content ) { $tmp = $path . '.tmp-' . bin2hex( random_bytes( 6 ) ); // phpcs:disable WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors // Windows refuses to rename over a file that is open: copy over it then. $ok = mb_strlen( $content, '8bit' ) === file_put_contents( $tmp, $content ) && ( @rename( $tmp, $path ) || @copy( $tmp, $path ) ); // phpcs:enable if ( file_exists( $tmp ) ) { wp_delete_file( $tmp ); } return $ok; } /** * Write the stylesheet to disk. * * @return bool True on success. */ public function generate() { $dir = $this->dir(); if ( ! wp_mkdir_p( $dir ) ) { return false; } // Keep the directory tidy / discourage listing. $index = trailingslashit( $dir ) . 'index.php'; if ( ! file_exists( $index ) ) { file_put_contents( $index, "write_file( $this->css_path(), $this->build_css() ); // Unused utility classes left out: the builder canvas gets every class from a second file. if ( Class_Usage::active() ) { $ok = $ok && $this->write_file( $this->full_css_path(), $this->build_css( true, false, true ) ); } elseif ( file_exists( $this->full_css_path() ) ) { wp_delete_file( $this->full_css_path() ); } // The shop pages sheet: written while it has something in it, removed otherwise. $shop = $this->build_shop_css(); if ( '' !== $shop ) { $ok = $ok && $this->write_file( $this->shop_css_path(), $shop ); } elseif ( file_exists( $this->shop_css_path() ) ) { wp_delete_file( $this->shop_css_path() ); } if ( $ok ) { update_option( self::OPTION_META, array( 'mtime' => (string) round( microtime( true ) * 1000 ), // ms: two saves in one second still differ (hot reload) 'version' => TOKENFORGE_VERSION, 'woo' => Woo::active(), // rebuild when WooCommerce is switched on or off 'gs' => Woo::uses_global_styles(), // or when a block theme comes or goes 'split' => Woo::split(), // or when a builder whose shop widgets can't be seen comes or goes ) ); } return $ok; } }