From 0a9f22429a2a7cf887090d9e26637bc124e83457 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 03:43:25 +0000 Subject: [PATCH 1/3] Cut comments back to a summary line and its tags Docblocks carried long explanations of why the code is the way it is: what core does and why that forced a choice, what was tried before, which decision a rule came from. That reasoning belongs in the docs, which already hold it; in the source it was 26% of every non-blank line and it went stale silently. Every docblock is now its short description plus @param/@return/@var. Inline comments are gone. Comment lines: 10,395 -> 6,495. Left alone, because something reads them: the plugin header and the theme patterns' own headers, which WordPress parses (reducing one deregisters the plugin or the pattern); phpcs:ignore annotations; translators: notes that WPCS requires; eslint directives; and one warning in the cloud porter -- "Never trust the wire, even our own service" -- which guards a re-sanitize somebody could otherwise read as redundant. Comments were located with PHP's own tokenizer and with Babel rather than by pattern-matching, so a "//" inside a string or a regex was never mistaken for one. To confirm nothing else moved, every changed file had all comments stripped from both its old and new version and the remaining code compared. Four differ, all of them the repo's own formatters run afterwards: eslint's curly rule adding braces, prettier unwrapping two method chains whose parentheses only existed because comments split them, one empty catch block collapsing to catch {}, and two CSS comments inside an inline @@ -803,7 +685,6 @@ private function document_around( $html, $pattern, $tile = false ) { if ( $tile ) { echo '
'; } - // Rendered block output, escaped by the blocks that produced it. echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped if ( $tile ) { echo '
'; diff --git a/includes/class-pattern-builder-rest-patterns-controller.php b/includes/class-pattern-builder-rest-patterns-controller.php index 24a1d13..0bafb83 100644 --- a/includes/class-pattern-builder-rest-patterns-controller.php +++ b/includes/class-pattern-builder-rest-patterns-controller.php @@ -11,23 +11,8 @@ /** * REST controller for block patterns. - * - * Follows the model of core's `WP_REST_Templates_Controller`: theme patterns - * are file-backed entities addressed by string IDs (their namespaced pattern - * name, e.g. `theme-slug/pattern-name`) with no database row behind them. - * Reads come from the pattern files; writes go back to the pattern files. - * - * The collection also lists user patterns (`wp_block` posts, numeric IDs) so - * one request paints the whole pattern library, but single-item routes address - * theme patterns only — user patterns remain managed by core's own - * `/wp/v2/blocks` endpoints. - * - * Registered as the REST controller of the rowless `pb_pattern` post type, so - * the block editor auto-registers a matching client-side entity from - * `/wp/v2/types`. */ class Pattern_Builder_REST_Patterns_Controller extends WP_REST_Controller { - /** * Post type. * @@ -187,10 +172,6 @@ public function get_item( $request ) { /** * Creates a theme pattern. * - * When `fromWpBlock` carries a wp_block post ID, that user pattern is - * converted: its content (with theme edits from the request applied) is - * written to a pattern file and the post is deleted. - * * @param WP_REST_Request $request The request. * @return WP_REST_Response|WP_Error */ @@ -258,10 +239,6 @@ public function create_item( $request ) { /** * Updates a theme pattern, writing its file. * - * A request whose `source` is `user` converts the theme pattern into a - * user pattern instead: the file is deleted and a wp_block post created. - * The response then describes the new user pattern (numeric `id`). - * * @param WP_REST_Request $request The request. * @return WP_REST_Response|WP_Error */ @@ -274,12 +251,7 @@ public function update_item( $request ) { $original = clone $pattern; $pattern = $this->apply_request_to_pattern( $pattern, $request ); - - /* - * A renamed pattern belongs in a file named after its new slug, so - * write a fresh file and drop the old one once that succeeds. - */ - $renamed = $pattern->name !== $original->name; + $renamed = $pattern->name !== $original->name; if ( $renamed ) { $pattern->filePath = null; $pattern->id = $pattern->name; @@ -458,12 +430,7 @@ protected function prepare_pattern_for_response( Abstract_Pattern $pattern, $req 'synced' => (bool) $pattern->synced, 'viewportWidth' => $pattern->viewportWidth, 'source' => $pattern->source, - // Where the pattern was first copied from, when it is somebody - // else's work; empty when it originated here (D38). Read-only: - // it is written on install and carried, never edited. 'origin' => (string) $pattern->origin, - // The name of this pattern's own copy on the cloud, or empty. - // Read-only too: uploads and installs write it. 'cloud' => (string) $pattern->cloud, ); @@ -482,12 +449,6 @@ protected function prepare_pattern_for_response( Abstract_Pattern $pattern, $req array( 'href' => rest_url( $this->namespace . '/' . $this->rest_base ) ), ), ); - - /* - * Action links, as core's posts controller advertises them. The - * editor's save button reads `wp:action-publish` off the record; - * without it, it assumes the user can only "Submit for Review". - */ if ( current_user_can( 'edit_theme_options' ) ) { $data['_links']['wp:action-publish'] = array( array( 'href' => $self ), diff --git a/includes/class-pattern-builder-security.php b/includes/class-pattern-builder-security.php index b7a261e..9246496 100644 --- a/includes/class-pattern-builder-security.php +++ b/includes/class-pattern-builder-security.php @@ -2,8 +2,6 @@ /** * Pattern Builder Security Helper * - * Provides security utilities for file operations and path validation. - * * @package Pattern_Builder */ @@ -12,60 +10,39 @@ use WP_Error; if ( ! defined( 'ABSPATH' ) ) { - exit; // Exit if accessed directly. + exit; } /** * Security helper class for Pattern Builder */ class Pattern_Builder_Security { - /** * Validate that a file path is within allowed directories. * * @param string $path The path to validate. - * @param array $allowed_dirs Optional. Array of allowed base directories. Defaults to theme directory. + * @param array $allowed_dirs Optional. * @return bool|WP_Error True if path is valid, WP_Error otherwise. */ public static function validate_file_path( $path, $allowed_dirs = array() ) { - // First normalize the path without realpath to handle non-existing files. $normalized_path = wp_normalize_path( $path ); - - /* - * Resolve the path as far as it goes, so it can be compared with - * directories resolved the same way below. A file that isn't there - * yet — the destination of a write or a move — resolves through the - * directory it will live in, which collapses any `..` just the same. - */ - $real_path = realpath( $path ); - $path = false !== $real_path + $real_path = realpath( $path ); + $path = false !== $real_path ? wp_normalize_path( $real_path ) : self::resolve_as_far_as_it_exists( $normalized_path ); - - // Default to theme directory if no allowed directories specified. if ( empty( $allowed_dirs ) ) { $allowed_dirs = array( get_stylesheet_directory(), get_template_directory(), ); } - - /* - * Resolve the allowed directories the same way the path above was - * resolved. A theme (or wp-content) reached through a symlink — the - * usual shape of a local dev setup — otherwise resolves to a real - * path that no unresolved allowed directory can ever match, and a - * legitimate write or delete looks like a traversal attempt. - */ $allowed_dirs = array_map( static function ( $dir ) { return self::resolve_as_far_as_it_exists( wp_normalize_path( $dir ) ); }, $allowed_dirs ); - - // Check if the path starts with any of the allowed directories. - $is_valid = false; + $is_valid = false; foreach ( $allowed_dirs as $allowed_dir ) { if ( 0 === strpos( $path, $allowed_dir ) ) { $is_valid = true; @@ -80,8 +57,6 @@ static function ( $dir ) { array( 'status' => 403 ) ); } - - // Additional check for suspicious patterns. if ( preg_match( '/\.\.\/|\.\.\\\\/', $path ) ) { return new WP_Error( 'suspicious_path', @@ -93,15 +68,9 @@ static function ( $dir ) { return true; } - /** * Resolve the deepest part of a path that exists, keeping the rest. * - * A file being written doesn't exist yet, and neither does the directory - * it goes in, on the first write — but everything above them does, and - * resolving that much is what collapses `..` and follows the symlinks - * that make a checkout look like a theme directory. - * * @param string $path Normalized path. * @return string */ @@ -121,7 +90,7 @@ private static function resolve_as_far_as_it_exists( $path ) { $parent = dirname( $candidate ); if ( $parent === $candidate ) { - return $path; // Nothing along the way exists. + return $path; } $missing[] = basename( $candidate ); @@ -157,33 +126,20 @@ public static function init_filesystem() { * * @param string $path The file path. * @param string $content The content to write. - * @param array $allowed_dirs Optional. Allowed directories for the file. + * @param array $allowed_dirs Optional. * @return bool|WP_Error True on success, WP_Error on failure. */ public static function safe_file_write( $path, $content, $allowed_dirs = array() ) { - // Validate the path first. $validation = self::validate_file_path( $path, $allowed_dirs ); if ( is_wp_error( $validation ) ) { return $validation; } - - // Initialize filesystem. $fs_init = self::init_filesystem(); if ( is_wp_error( $fs_init ) ) { return $fs_init; } global $wp_filesystem; - - /* - * Ensure the directory exists. `wp_mkdir_p()` refuses outright any - * path carrying a `..` segment, so a theme root that reaches its - * themes through one — as a registered theme root may, and as the - * test fixtures do — would fail to create the directory rather than - * be denied it, with "could not create" standing in for a traversal - * guard that was never the point. Resolving collapses it the same way - * `validate_file_path()` already did before allowing the write. - */ $dir = self::resolve_as_far_as_it_exists( wp_normalize_path( dirname( $path ) ) ); if ( ! $wp_filesystem->is_dir( $dir ) ) { if ( ! wp_mkdir_p( $dir ) ) { @@ -194,8 +150,6 @@ public static function safe_file_write( $path, $content, $allowed_dirs = array() ); } } - - // Write the file. $result = $wp_filesystem->put_contents( $path, $content, FS_CHMOD_FILE ); if ( false === $result ) { @@ -213,25 +167,20 @@ public static function safe_file_write( $path, $content, $allowed_dirs = array() * Safely delete a file using WordPress Filesystem API. * * @param string $path The file path to delete. - * @param array $allowed_dirs Optional. Allowed directories for the file. + * @param array $allowed_dirs Optional. * @return bool|WP_Error True on success, WP_Error on failure. */ public static function safe_file_delete( $path, $allowed_dirs = array() ) { - // Validate the path first. $validation = self::validate_file_path( $path, $allowed_dirs ); if ( is_wp_error( $validation ) ) { return $validation; } - - // Initialize filesystem. $fs_init = self::init_filesystem(); if ( is_wp_error( $fs_init ) ) { return $fs_init; } global $wp_filesystem; - - // Check if file exists. if ( ! $wp_filesystem->exists( $path ) ) { return new WP_Error( 'file_not_found', @@ -239,8 +188,6 @@ public static function safe_file_delete( $path, $allowed_dirs = array() ) { array( 'status' => 404 ) ); } - - // Delete the file. $result = $wp_filesystem->delete( $path ); if ( false === $result ) { @@ -259,11 +206,10 @@ public static function safe_file_delete( $path, $allowed_dirs = array() ) { * * @param string $source The source file path. * @param string $destination The destination file path. - * @param array $allowed_dirs Optional. Allowed directories for both paths. + * @param array $allowed_dirs Optional. * @return bool|WP_Error True on success, WP_Error on failure. */ public static function safe_file_move( $source, $destination, $allowed_dirs = array() ) { - // Validate both paths. $source_validation = self::validate_file_path( $source, $allowed_dirs ); if ( is_wp_error( $source_validation ) ) { return $source_validation; @@ -273,16 +219,12 @@ public static function safe_file_move( $source, $destination, $allowed_dirs = ar if ( is_wp_error( $dest_validation ) ) { return $dest_validation; } - - // Initialize filesystem. $fs_init = self::init_filesystem(); if ( is_wp_error( $fs_init ) ) { return $fs_init; } global $wp_filesystem; - - // Ensure destination directory exists. $dest_dir = dirname( $destination ); if ( ! $wp_filesystem->is_dir( $dest_dir ) ) { if ( ! wp_mkdir_p( $dest_dir ) ) { @@ -293,8 +235,6 @@ public static function safe_file_move( $source, $destination, $allowed_dirs = ar ); } } - - // Move the file. $result = $wp_filesystem->move( $source, $destination, true ); if ( false === $result ) { @@ -304,15 +244,6 @@ public static function safe_file_move( $source, $destination, $allowed_dirs = ar array( 'status' => 500 ) ); } - - /* - * A move carries the source file's permissions across, and a moved-in - * file can arrive with a mode no web server will serve: an image the - * image editor wrote is chmodded from the temporary directory's own - * mode, so a resize in /tmp (0777) lands the result world-writable, - * which suEXEC hosts refuse with a 403. Normalise to the same mode - * every other write in this plugin uses. - */ $wp_filesystem->chmod( $destination, FS_CHMOD_FILE ); return true; diff --git a/includes/class-pattern-builder-telemetry.php b/includes/class-pattern-builder-telemetry.php index 29df765..323f921 100644 --- a/includes/class-pattern-builder-telemetry.php +++ b/includes/class-pattern-builder-telemetry.php @@ -12,31 +12,9 @@ use WP_REST_Response; /** - * Anonymous usage reporting, only ever with the site administrator's - * explicit say-so. - * - * WordPress.org's guidelines forbid tracking without opt-in, and this is - * built to make the rule easy to keep: nothing is sent until an - * administrator has clicked Allow on the prompt the pattern browser shows - * once, and one click on the prompt (or the connect panel, which offers - * it again to a site that declined) turns it off. The answer is a site - * option — one decision per site, recorded with who made it and when. - * - * What is sent: a random install id minted at opt-in (never the site's - * URL, name or address), the environment (WordPress, PHP and plugin - * versions, locale, theme slug, multisite, environment type), and named - * events — the browser opened, a pattern created, the community browsed, - * an upload — each with a small fixed set of properties. Nothing about - * content, ever. It goes to patternbuilderwp.com, which relays it to the - * analytics project: the plugin therefore names one service, and never - * loads a script from anyone. - * - * Events recorded during a request are buffered and posted once, on - * shutdown, without waiting for the answer. A lost batch is lost; this is - * analytics, not accounting. + * Anonymous usage reporting, only ever with the site administrator's explicit say-so. */ class Pattern_Builder_Telemetry { - const OPTION = 'pattern_builder_telemetry'; const ALLOWED = 'allowed'; @@ -105,10 +83,6 @@ public static function is_decided() { /** * Record the decision. * - * Allowing mints the install id if there is none; declining keeps it, - * so a site that allows again later is the same site in the numbers. - * Each change is itself the last (or first) event sent. - * * @param bool $allow The answer. * @return array The new state. */ @@ -125,7 +99,7 @@ public static function set_consent( $allow ) { } if ( ! $allow && $was ) { - self::record( 'telemetry_disabled' ); // Buffered while still allowed. + self::record( 'telemetry_disabled' ); } update_option( self::OPTION, $state, false ); @@ -140,7 +114,7 @@ public static function set_consent( $allow ) { /** * Record an event, if the site allows it. * - * @param string $event Event name, from the service's fixed list. + * @param string $event Event name, from the service's fixed list. * @param array $properties Event properties, from the service's fixed list. */ public static function record( $event, $properties = array() ) { @@ -175,7 +149,7 @@ public static function flush() { /** * Filters whether a batch is sent, and lets tests see it. * - * @param bool $send Whether to send. + * @param bool $send Whether to send. * @param array $events The batch. */ if ( ! apply_filters( 'pattern_builder_telemetry_send', true, $events ) ) { @@ -227,8 +201,8 @@ public static function environment() { } /** - * The connected account, when there is one, so cloud usage joins the - * website's events under the same account. + * The connected account, when there is one, so cloud usage joins the website's events + * under the same account. * * @return array */ @@ -261,7 +235,7 @@ public static function client_state() { */ public static function on_activation() { self::record( 'plugin_activated' ); - self::flush(); // No shutdown to wait for on an activation request. + self::flush(); } /** @@ -273,8 +247,7 @@ public static function on_deactivation() { } /** - * REST routes for the browse app: read the state, answer the prompt, - * report an event. Gated like every other management route. + * REST routes for the browse app: read the state, answer the prompt, report an event. */ public function register_routes() { $can_manage = function () { @@ -330,9 +303,6 @@ public function consent( $request ) { /** * POST /telemetry/event — the browse app saw something happen. * - * The service keeps its own allowlist; this passes only string and - * scalar properties through, so the wire never carries content. - * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ diff --git a/includes/class-pattern-builder-theme-json.php b/includes/class-pattern-builder-theme-json.php index e567125..285cbe3 100644 --- a/includes/class-pattern-builder-theme-json.php +++ b/includes/class-pattern-builder-theme-json.php @@ -16,15 +16,8 @@ /** * The active theme's `theme.json`, or the site's user Global Styles. - * - * Two destinations, one shape: a theme.json-shaped array that is loaded, - * changed and written back. `Pattern_Builder_Cloud_Tokens` merges presets into - * it and `Pattern_Builder_Theme_Styles` merges styles, and neither of them - * should have to know that one destination is a file on disk and the other a - * post — or repeat the four ways loading can fail. */ class Pattern_Builder_Theme_Json { - /** * Read the config for a destination. * @@ -43,8 +36,6 @@ public static function load( $destination ) { if ( ! is_array( $config ) ) { $config = array(); } - - // Global Styles is stored as theme.json with two markers on it. $config['version'] = isset( $config['version'] ) ? $config['version'] : 3; $config['isGlobalStylesUserThemeJSON'] = true; @@ -71,7 +62,7 @@ public static function load( $destination ) { * Write a config back to its destination. * * @param string $destination "theme" or "user". - * @param array $config theme.json-shaped config. + * @param array $config theme.json-shaped config. * @return true|WP_Error */ public static function save( $destination, $config ) { @@ -98,8 +89,6 @@ public static function save( $destination, $config ) { return new WP_Error( 'pb_cloud_theme_json_write', __( 'theme.json could not be written.', 'pattern-builder' ), array( 'status' => 500 ) ); } } - - // Whatever just changed, the merged data every reader sees is stale. wp_clean_theme_json_cache(); return true; @@ -109,7 +98,7 @@ public static function save( $destination, $config ) { * Load a config, hand it to a merger, and write the result back. * * @param string $destination "theme" or "user". - * @param callable $merge Takes the config, returns it changed. + * @param callable $merge Takes the config, returns it changed. * @return true|WP_Error */ public static function edit( $destination, $merge ) { diff --git a/includes/class-pattern-builder-theme-styles.php b/includes/class-pattern-builder-theme-styles.php index ac83594..3435ecc 100644 --- a/includes/class-pattern-builder-theme-styles.php +++ b/includes/class-pattern-builder-theme-styles.php @@ -16,26 +16,12 @@ /** * Writing `styles` into theme.json or Global Styles. - * - * A preset is a value a pattern *references*; a style is one it *inherits*, - * and the difference decides everything about how this class behaves next to - * `Pattern_Builder_Cloud_Tokens`. - * - * A preset is additive and inert — adding one changes nothing on the site - * until some block names it — so tokens are never overwritten and a collision - * is reported as skipped. A style is neither: there is one - * `styles.elements.link.color.text`, setting it replaces whatever was there, - * and it repaints every link on every page the moment it lands. So this - * replaces where the tokens never do, and it is deliberately not reachable - * from the cloud download path — a pattern that arrived from somewhere else - * must not repaint the site it arrived at. */ class Pattern_Builder_Theme_Styles { - /** * Merge styles into a destination. * - * @param array $styles A theme.json `styles` subtree. + * @param array $styles A theme.json `styles` subtree. * @param string $destination "theme" or "user". * @return array|WP_Error { destination, written, skipped } */ @@ -92,19 +78,6 @@ function ( $config ) use ( $clean ) { /** * Refuse a styles tree carrying raw CSS. * - * This is the *global styles* rule, and it stays absolute. WordPress does - * not sanitize a theme.json `css` property — it gates it on `edit_css` - * instead, and says so in a comment — so a string that closes its own - * selector writes rules for the whole document. A `css` at `styles.css` - * or on an element node is scoped to nothing a pattern brought with it: - * core emits it against the document, and a pattern that arrived from - * somewhere else must not repaint the site it arrived at. - * - * A block style variation is the one place that opens up, because there - * the selector is a class the pattern's own markup carries. - * `Pattern_Builder_Block_Style_Variations` holds that rule, and what it - * accepts it puts through `Safe_Css` first. - * * @param array $styles A theme.json `styles` subtree. * @return true|WP_Error */ @@ -128,28 +101,10 @@ public static function check_css( $styles ) { /** * Drop anything WordPress would not accept as a style. * - * Core's own schema does this — the same pass a theme.json gets when - * WordPress reads it, so it stays right across releases in a way a - * hand-written property list would not. What it drops is reported rather - * than silently lost, since an agent that believes it set a property and - * did not will go on to build against a design that isn't there. - * * @param array $styles A theme.json `styles` subtree. * @return array|WP_Error */ public static function sanitize( $styles ) { - /* - * A `styles.blocks.{block}.variations.{slug}` node is kept only while - * `{slug}` is in the block style registry, and a variation this theme - * defines as a `styles/*.json` partial is registered lazily — by - * `WP_Theme_JSON_Resolver::get_theme_data()`, the first time something - * asks for the theme's data. Nothing may have asked yet in this - * request, in which case the node would be dropped and reported as - * unrecognised for a variation that plainly exists. This is also the - * one way to give a variation a block *state*: a partial is read as a - * whole-theme styles tree, which has no `:hover`, so a button - * variation's hover colour lives here and nowhere else. - */ if ( self::names_a_variation( $styles ) && class_exists( '\WP_Theme_JSON_Resolver' ) ) { \WP_Theme_JSON_Resolver::get_theme_data(); } @@ -188,10 +143,7 @@ private static function names_a_variation( $styles ) { /** * Every place a `css` property appears, as dotted paths. * - * Public because the block style variation writer allows one of them — - * the variation's own `styles.css` — and has to find the rest. - * - * @param array $node Styles subtree. + * @param array $node Styles subtree. * @param string $prefix Path so far. * @return string[] */ @@ -215,11 +167,8 @@ public static function find_css( $node, $prefix = '' ) { /** * Leaf paths present in the first tree and not the second. * - * Public because the block style variation writer sanitizes the same way - * and owes an agent the same account of what was dropped. - * - * @param array $given What was asked for. - * @param array $kept What survived sanitization. + * @param array $given What was asked for. + * @param array $kept What survived sanitization. * @param string $prefix Path so far. * @return string[] */ @@ -244,7 +193,7 @@ public static function missing_paths( $given, $kept, $prefix = '' ) { /** * The leaf paths a styles tree sets. * - * @param array $node Styles subtree. + * @param array $node Styles subtree. * @param string $prefix Path so far. * @return string[] */ @@ -266,10 +215,6 @@ public static function paths( $node, $prefix = '' ) { /** * Merge incoming styles over existing ones, leaf by leaf. * - * Deep rather than wholesale: setting `elements.link.color.text` should - * not take `elements.button` with it, since an agent sets the one thing - * it means to change and has no reason to restate the rest. - * * @param array $existing Styles already in the config. * @param array $incoming Styles to write. * @return array diff --git a/includes/class-pattern-builder.php b/includes/class-pattern-builder.php index 8dc6e79..57d17ea 100644 --- a/includes/class-pattern-builder.php +++ b/includes/class-pattern-builder.php @@ -36,15 +36,8 @@ /** * Main class for managing the Pattern Builder plugin. - * - * Always registers the full stack — the pattern runtime (vendored from the - * companion Synced Patterns for Themes plugin, kept logic-identical) and the - * editing layer on top. When both plugins are installed, the companion - * detects Pattern Builder at `plugins_loaded` and stays entirely unloaded; - * this plugin never has to coordinate. */ class Pattern_Builder { - /** * Singleton instance. * @@ -58,8 +51,6 @@ class Pattern_Builder { private function __construct() { ( new Pattern_Block() )->register(); ( new Editor_Support( PATTERN_BUILDER_FILE ) )->register(); - - // A theme switch changes which pattern files the synced lookup reads. add_action( 'switch_theme', array( Synced_Patterns::class, 'flush' ) ); new Pattern_Builder_Entity(); diff --git a/includes/class-pattern-file-store.php b/includes/class-pattern-file-store.php index c35483a..71e9f4c 100644 --- a/includes/class-pattern-file-store.php +++ b/includes/class-pattern-file-store.php @@ -13,29 +13,23 @@ /** * Reads and writes block patterns. - * - * Theme patterns live in PHP files in the theme's (and parent theme's) - * `patterns/` directory — the files are the only source of truth, nothing is - * mirrored into the database. User patterns are core `wp_block` posts and are - * only touched here for listing and conversion. */ class Pattern_File_Store { - /** - * Post meta holding a user pattern's attribution — the same thing the - * `Origin:` header holds for a theme pattern (D38). + * Post meta holding a user pattern's attribution — the same thing the `Origin:` header + * holds for a theme pattern (D38). */ const META_ORIGIN = 'pattern_builder_origin'; /** - * Post meta holding the name of a user pattern's copy on the cloud — the - * same thing the `Cloud:` header holds for a theme pattern. + * Post meta holding the name of a user pattern's copy on the cloud — the same thing the + * `Cloud:` header holds for a theme pattern. */ const META_CLOUD = 'pattern_builder_cloud'; /** - * Returns all patterns found as PHP files in the active theme's and the - * parent theme's `patterns/` directories. + * Returns all patterns found as PHP files in the active theme's and the parent theme's + * `patterns/` directories. * * @return Abstract_Pattern[] */ @@ -48,7 +42,6 @@ public function get_theme_patterns() { $pattern = Abstract_Pattern::from_file( $pattern_file ); if ( '' === $pattern->name || isset( $seen[ $pattern->name ] ) ) { - // A child theme pattern overrides a parent pattern with the same slug. continue; } @@ -63,13 +56,6 @@ public function get_theme_patterns() { /** * Every pattern file under a directory, however deep. * - * Core scans `patterns/` to unlimited depth (`WP_Theme::scandir()` with - * a depth of -1), so a pattern in a subdirectory is a pattern - * WordPress already registers. This plugin files installed patterns - * that way — `patterns/{handle}/{collection}/{slug}.php` — so that two - * accounts' patterns of the same name can both live here, and so a - * pattern's namespace is legible from the theme's file tree. - * * @param string $directory Directory to scan. * @return string[] Absolute file paths. */ @@ -132,12 +118,6 @@ public function get_user_patterns(): array { /** * Every cloud name a local pattern answers to, with the pattern it names. * - * A pattern answers to one in two ways: a theme pattern installed from - * the cloud keeps its cloud name as its own, and any pattern with a copy - * on the cloud carries that copy's name as its `Cloud:` reference. Read - * from the files' headers and one meta query, rendering nothing, so a - * whole collection's worth of "is this installed here?" costs one pass. - * * @return array name => { type: string, id: string|int, title: string } */ public function cloud_names() { @@ -160,8 +140,6 @@ public function cloud_names() { } foreach ( array( $slug, trim( $headers['cloud'] ) ) as $name ) { - // A child theme's pattern wins over its parent's, as it does - // everywhere else. if ( 2 === substr_count( $name, '/' ) && ! isset( $names[ $name ] ) ) { $names[ $name ] = array( 'type' => 'theme', @@ -200,7 +178,8 @@ public function cloud_names() { * Updates a theme pattern by writing its PHP file. * * @param Abstract_Pattern $pattern The pattern to update. - * @param array $options Optional settings: 'localize' (bool), 'import_images' (bool). + * @param array $options Optional settings: 'localize' (bool), + * 'import_images' (bool). * @return Abstract_Pattern|WP_Error The pattern as re-read from disk, or an error. */ public function update_theme_pattern( Abstract_Pattern $pattern, $options = array() ) { @@ -211,27 +190,13 @@ public function update_theme_pattern( Abstract_Pattern $pattern, $options = arra array( 'status' => 403 ) ); } - - /* - * A theme pattern's name is what everything else refers to it by, and - * WordPress reads it from the file's `Slug:` header, so a bare name - * registers as `hero` while every `core/pattern` reference written - * against the documented `{theme}/hero` resolves to nothing — silently, - * because an unresolved reference renders as nothing rather than as an - * error. Namespacing here rather than at each caller is what keeps the - * name a caller passes and the name WordPress registers the same. - */ $pattern->name = self::namespaced_name( $pattern->name ); if ( 'theme' === $pattern->source ) { $pattern->id = $pattern->name; } - - // Import images unless explicitly disabled. if ( ! isset( $options['import_images'] ) || true === $options['import_images'] ) { $pattern = $this->import_pattern_image_assets( $pattern ); } - - // Localize if enabled. if ( isset( $options['localize'] ) && true === $options['localize'] ) { $pattern = Pattern_Builder_Localization::localize_pattern_content( $pattern ); } @@ -243,8 +208,6 @@ public function update_theme_pattern( Abstract_Pattern $pattern, $options = arra } $this->flush_pattern_caches(); - - // Rebuild the pattern from the file (so that content has no PHP tags). $filepath = $this->get_pattern_filepath( $pattern ); if ( ! is_wp_error( $filepath ) && $filepath ) { $pattern = Abstract_Pattern::from_file( $filepath ); @@ -256,12 +219,8 @@ public function update_theme_pattern( Abstract_Pattern $pattern, $options = arra /** * Record which cloud pattern a local pattern is a copy of, or forget it. * - * A theme pattern's reference is a header, so setting one rewrites the - * file through the same door an editor save does; nothing is written - * when the reference is already the one asked for. - * * @param Abstract_Pattern $pattern The local pattern. - * @param string $name `{handle}/{collection}/{slug}`, or '' to clear it. + * @param string $name `{handle}/{collection}/{slug}`, or '' to clear it. * @return true|WP_Error */ public function set_cloud_reference( Abstract_Pattern $pattern, $name ) { @@ -288,8 +247,9 @@ public function set_cloud_reference( Abstract_Pattern $pattern, $name ) { /** * Creates a theme pattern from a user pattern (wp_block), deleting the post. * - * @param \WP_Post $post The wp_block post to convert. - * @param Abstract_Pattern $pattern The pattern data to write (already carrying any edits). + * @param \WP_Post $post The wp_block post to convert. + * @param Abstract_Pattern $pattern The pattern data to write (already carrying any + * edits). * @param array $options Optional settings passed to update_theme_pattern(). * @return Abstract_Pattern|WP_Error The new theme pattern, or an error. */ @@ -322,7 +282,8 @@ public function convert_user_pattern_to_theme( $post, Abstract_Pattern $pattern, * Converts a theme pattern into a user pattern (wp_block), deleting the file. * * @param Abstract_Pattern $pattern The theme pattern to convert. - * @return Abstract_Pattern|WP_Error The new user pattern (with its post ID), or an error. + * @return Abstract_Pattern|WP_Error The new user pattern (with its post ID), or an + * error. */ public function convert_theme_pattern_to_user( Abstract_Pattern $pattern ) { if ( ! current_user_can( 'edit_theme_options' ) ) { @@ -334,9 +295,7 @@ public function convert_theme_pattern_to_user( Abstract_Pattern $pattern ) { } $filepath = $this->get_pattern_filepath( $pattern ); - - // Export any theme assets to the media library. - $pattern = $this->export_pattern_image_assets( $pattern ); + $pattern = $this->export_pattern_image_assets( $pattern ); $post_id = wp_insert_post( array( @@ -361,16 +320,12 @@ public function convert_theme_pattern_to_user( Abstract_Pattern $pattern ) { } wp_set_object_terms( $post_id, $pattern->categories, 'wp_pattern_category', false ); - - // Still the same pattern: its attribution and its cloud copy come along. if ( $pattern->origin ) { update_post_meta( $post_id, self::META_ORIGIN, $pattern->origin ); } if ( $pattern->cloud ) { update_post_meta( $post_id, self::META_CLOUD, $pattern->cloud ); } - - // Delete the theme pattern file. if ( ! is_wp_error( $filepath ) && $filepath ) { $deleted = Pattern_Builder_Security::safe_file_delete( $filepath, @@ -455,10 +410,6 @@ public function get_pattern_filepath( $pattern ) { /** * A theme pattern's fully namespaced name. * - * A name that already carries a namespace is left alone — that covers the - * theme's own `{theme}/hero` and a cloud pattern's permanent - * `{handle}/{collection}/{slug}`, neither of which may be rewritten. - * * @param string $name Pattern name, namespaced or bare. * @return string */ @@ -475,25 +426,12 @@ public static function namespaced_name( $name ) { /** * The file a pattern's name implies, under the active theme. * - * A pattern's name is its namespace, and the namespace is the path. - * The theme's own patterns keep the flat layout every theme uses — - * `mytheme/hero` is `patterns/hero.php` — because the theme slug is - * the theme's whole namespace and a directory named after the theme - * inside the theme says nothing. Everything else keeps its namespace - * as directories: a pattern installed from the cloud is - * `{handle}/{collection}/{slug}`, so it lands in - * `patterns/{handle}/{collection}/{slug}.php` and cannot collide with - * another account's pattern of the same name. - * * @param string $name Namespaced pattern name. * @return string Absolute path. */ private function path_for_name( $name ) { $segments = array_values( array_filter( explode( '/', (string) $name ), 'strlen' ) ); $slug = array_pop( $segments ); - - // A single leading segment naming this theme is the theme's own - // namespace, and is not a directory. if ( 1 === count( $segments ) && in_array( $segments[0], array( get_stylesheet(), get_template() ), true ) ) { $segments = array(); } @@ -506,15 +444,11 @@ private function path_for_name( $name ) { /** * Writes a theme pattern's PHP file to disk. * - * Creates the file if it doesn't exist. Content is formatted before writing. - * * @param Abstract_Pattern $pattern The pattern to write. * @return Abstract_Pattern|WP_Error */ public function update_theme_pattern_file( Abstract_Pattern $pattern ) { $path = $this->get_pattern_filepath( $pattern ); - - // If get_pattern_filepath returns an error, construct a new path. if ( is_wp_error( $path ) ) { $path = $this->path_for_name( $pattern->name ); } @@ -538,12 +472,6 @@ public function update_theme_pattern_file( Abstract_Pattern $pattern ) { /** * Forgets every cache derived from the theme's pattern files. * - * Covers core's per-theme pattern header cache, this plugin's synced-slug - * lookup, and the Synced Patterns for Themes transient. The companion - * stays unloaded while this plugin is active, but its week-long cache may - * survive from before — clearing it here keeps the companion current if - * this plugin is ever deactivated. - * * @return void */ public function flush_pattern_caches() { @@ -584,7 +512,6 @@ private function get_pattern_directories() { * @return string PHP header comment string. */ private function build_pattern_file_metadata( Abstract_Pattern $pattern ): string { - $categories = $pattern->categories ? "\n * Categories: " . implode( ', ', $pattern->categories ) : ''; $keywords = $pattern->keywords ? "\n * Keywords: " . implode( ', ', $pattern->keywords ) : ''; $blockTypes = $pattern->blockTypes ? "\n * Block Types: " . implode( ', ', $pattern->blockTypes ) : ''; @@ -593,11 +520,8 @@ private function build_pattern_file_metadata( Abstract_Pattern $pattern ): strin $viewportWidth = $pattern->viewportWidth ? "\n * Viewport Width: " . (int) $pattern->viewportWidth : ''; $inserter = $pattern->inserter ? '' : "\n * Inserter: no"; $synced = $pattern->synced ? "\n * Synced: yes" : ''; - // Attribution travels with the pattern, so it goes in the file (D38). - // Core reads a fixed list of headers and ignores the rest, so this is - // inert to WordPress and legible to anyone who opens the file. - $origin = $pattern->origin ? "\n * Origin: " . $pattern->origin : ''; - $cloud = $pattern->cloud ? "\n * Cloud: " . $pattern->cloud : ''; + $origin = $pattern->origin ? "\n * Origin: " . $pattern->origin : ''; + $cloud = $pattern->cloud ? "\n * Cloud: " . $pattern->cloud : ''; $metadata = "content = preg_replace_callback( '/(src|href)="(' . preg_quote( $home_url, '/' ) . '[^"]+)"/', function ( $matches ) use ( $upload_image ) { @@ -740,8 +650,6 @@ function ( $matches ) use ( $upload_image ) { }, $pattern->content ); - - // Handle JSON-encoded URLs. $pattern->content = preg_replace_callback( '/"url"\s*:\s*"(' . preg_quote( $home_url, '/' ) . '[^"]+)"/', function ( $matches ) use ( $upload_image ) { @@ -759,16 +667,13 @@ function ( $matches ) use ( $upload_image ) { } /** - * Imports pattern image assets from the media library into the theme's assets directory. - * - * Used when saving a theme pattern — downloads URLs pointing to home_url and - * stores them as static theme assets, replacing the URLs with PHP template tags. + * Imports pattern image assets from the media library into the theme's assets + * directory. * * @param Abstract_Pattern $pattern The pattern whose images should be imported. * @return Abstract_Pattern Updated pattern with theme-relative asset paths. */ private function import_pattern_image_assets( $pattern ) { - $home_url = home_url(); /** @@ -778,7 +683,6 @@ private function import_pattern_image_assets( $pattern ) { * @return string|false Theme-relative path on success, false on failure. */ $download_and_save_image = function ( $url ) { - // Skip if the asset isn't an image. if ( ! preg_match( '/\.(jpg|jpeg|png|gif|webp|svg)$/i', $url ) ) { return false; } @@ -786,7 +690,6 @@ private function import_pattern_image_assets( $pattern ) { $download_file = download_url( $url ); if ( is_wp_error( $download_file ) ) { - // Try again with port 80 if we're inside a Docker container on localhost. $parsed_url = wp_parse_url( $url ); if ( 'localhost' === $parsed_url['host'] && '80' !== ( $parsed_url['port'] ?? null ) ) { $download_file = download_url( str_replace( 'localhost:' . $parsed_url['port'], 'localhost:80', $url ) ); @@ -821,9 +724,7 @@ private function import_pattern_image_assets( $pattern ) { return '/assets/images/' . $filename; }; - - // Handle HTML attributes (src and href). - $pattern->content = preg_replace_callback( + $pattern->content = preg_replace_callback( '/(src|href)="(' . preg_quote( $home_url, '/' ) . '[^"]+)"/', function ( $matches ) use ( $download_and_save_image ) { $new_url = $download_and_save_image( $matches[2] ); @@ -834,9 +735,7 @@ function ( $matches ) use ( $download_and_save_image ) { }, $pattern->content ); - - // Handle JSON-encoded URLs. - $pattern->content = preg_replace_callback( + $pattern->content = preg_replace_callback( '/"url"\s*:\s*"(' . preg_quote( $home_url, '/' ) . '[^"]+)"/', function ( $matches ) use ( $download_and_save_image ) { $new_url = $download_and_save_image( $matches[1] ); @@ -854,8 +753,6 @@ function ( $matches ) use ( $download_and_save_image ) { /** * Formats block markup for readability. * - * This is a PHP port of the JavaScript formatBlockMarkup() function. - * * @param string $block_markup The block markup to format. * @return string Formatted block markup. */ @@ -872,7 +769,6 @@ public function format_block_markup( $block_markup ) { * @return string Block markup with newlines added. */ private function add_new_lines_to_block_markup( $block_markup ) { - // Add newlines before and after each comment. $block_markup = preg_replace_callback( '//s', function ( $matches ) { @@ -881,14 +777,8 @@ function ( $matches ) { }, $block_markup ); - - // Fix spacing for self-closing blocks. $block_markup = str_replace( '/ -->', '/-->', $block_markup ); - - // Normalize multiple newlines into a single one. $block_markup = preg_replace( '/\n{2,}/', "\n", $block_markup ); - - // Eliminate blank lines. $block_markup = preg_replace( '/^\s*[\r\n]/m', '', $block_markup ); return $block_markup; @@ -908,7 +798,6 @@ private function indent_block_markup( $block_markup ) { $output = array(); foreach ( $lines as $line ) { - // Detect closing tags/comments — reduce indent before rendering. $is_closing_comment = preg_match( '/^$/', $line ); $is_closing_tag = preg_match( '/^<\/[\w:-]+>$/', $line ); @@ -916,19 +805,11 @@ private function indent_block_markup( $block_markup ) { $indent_level = max( $indent_level - 1, 0 ); } - $output[] = str_repeat( $indent_str, $indent_level ) . $line; - - // Detect opening comment (not self-closing). - $is_opening_comment = preg_match( '/^$/', $line ) && + $output[] = str_repeat( $indent_str, $indent_level ) . $line; + $is_opening_comment = preg_match( '/^$/', $line ) && ! preg_match( '/\/\s*-->$/', $line ); - - // Detect opening tag (not self-closing). - $is_opening_tag = preg_match( '/^<([\w:-]+)(\s[^>]*)?>$/', $line ); - - // Self-closing HTML tag. - $is_self_closing_tag = preg_match( '/^<[^>]+\/>$/', $line ); - - // Self-closing block markup. + $is_opening_tag = preg_match( '/^<([\w:-]+)(\s[^>]*)?>$/', $line ); + $is_self_closing_tag = preg_match( '/^<[^>]+\/>$/', $line ); $is_self_closing_comment = preg_match( '/^$/', $line ); if ( ( $is_opening_comment || $is_opening_tag ) && ! $is_self_closing_tag && ! $is_self_closing_comment ) { diff --git a/includes/class-pattern-resolver.php b/includes/class-pattern-resolver.php index e1c4ff0..f2445b7 100644 --- a/includes/class-pattern-resolver.php +++ b/includes/class-pattern-resolver.php @@ -10,39 +10,18 @@ use WP_Block_Patterns_Registry; /** - * Replaces `core/pattern` blocks that carry content with the referenced - * pattern's blocks, with the content written into them. - * - * The front end does not need this: there, `Pattern_Block` puts the content in - * block context and core's `core/pattern-overrides` binding source resolves it - * while rendering. The editor does, because it works with blocks rather than - * rendered HTML — and because core flattens `core/pattern` blocks server side - * (`resolve_pattern_blocks()`) before the editor ever sees them, which drops - * the content attribute along the way. - * - * So for editor-facing markup the composition happens here first, and the - * result is ordinary editable content: values written into the markup, and the - * `core/pattern-overrides` bindings that asked for them removed. Whatever this - * leaves behind is a plain pattern block that core resolves as it always has. - * - * A reference to a *synced* pattern is the one thing never composed. It is a - * reference by definition — the editor renders it as an instance, design - * locked and slots editable, and the front end renders it from the file — so - * writing its content in would hand the editor a copy with the design - * unlocked and the link gone. It is kept exactly as written, content and all, - * and `compose()` exists so the editor's pattern list can inline everything - * else the way core does without core's resolver flattening these too. + * Replaces `core/pattern` blocks that carry content with the referenced pattern's blocks, + * with the content written into them. */ class Pattern_Resolver { - /** * The block bindings source that marks a pattern's content slots. */ const OVERRIDES_SOURCE = 'core/pattern-overrides'; /** - * How many patterns have been expanded, for detecting whether a subtree - * needed this resolver at all. + * How many patterns have been expanded, for detecting whether a subtree needed this + * resolver at all. * * @var int */ @@ -56,8 +35,8 @@ class Pattern_Resolver { private static $expanding = array(); /** - * Whether plain pattern blocks — no content, none inside — are inlined - * too, the way core's own resolver inlines them. Set by `compose()`. + * Whether plain pattern blocks — no content, none inside — are inlined too, the way + * core's own resolver inlines them. * * @var bool */ @@ -66,10 +45,6 @@ class Pattern_Resolver { /** * Cheap test for markup that might contain a pattern block. * - * Parsing every pattern and template would be wasteful on sites that do not - * use the feature, and every one of them would have to be parsed to find - * out. - * * @param mixed $markup Block markup. * @return bool Whether the markup is worth parsing. */ @@ -81,8 +56,8 @@ public static function contains_pattern_block( $markup ): bool { * Composes every pattern with content in a piece of block markup. * * @param string $markup Block markup. - * @return string Block markup with those patterns composed into it, or the - * markup untouched if there were none. + * @return string Block markup with those patterns composed into it, or the markup + * untouched if there were none. */ public static function resolve( string $markup ): string { if ( ! self::contains_pattern_block( $markup ) ) { @@ -98,17 +73,9 @@ public static function resolve( string $markup ): string { /** * Composes a pattern the way the editor's pattern list needs it. * - * Core flattens every `core/pattern` block in that list server side - * (`resolve_pattern_blocks()`), which loses the content attribute and - * turns a synced reference into a copy. This does core's job instead: - * content is written in, plain references are inlined the way core - * inlines them, and synced references are left as written for the - * editor to render as instances. - * * @param string $markup Block markup. - * @return string Block markup with every pattern block composed into it, - * except the synced references, or the markup untouched if - * there were none. + * @return string Block markup with every pattern block composed into it, except the + * synced references, or the markup untouched if there were none. */ public static function compose( string $markup ): string { if ( ! self::contains_pattern_block( $markup ) ) { @@ -151,29 +118,18 @@ public static function resolve_blocks( array $blocks ): array { /** * Resolves a single parsed block. * - * A pattern block becomes the blocks it stands for, which is why this - * returns a list rather than a block. - * * @param array $block A parsed block. * @return array[] The blocks that replace it. */ private static function resolve_block( array $block ): array { if ( 'core/pattern' === ( $block['blockName'] ?? null ) ) { $expanded = self::expand_pattern_block( $block ); - - // Null means core's own resolver can take this one from here. return null === $expanded ? array( $block ) : $expanded; } if ( empty( $block['innerBlocks'] ) || empty( $block['innerContent'] ) ) { return array( $block ); } - - /* - * `serialize_block()` walks `innerContent` and consumes one inner block - * for every null in it, so the two have to be rebuilt together: a - * pattern standing in one null slot may resolve to any number of blocks. - */ $inner_blocks = array(); $inner_content = array(); $index = 0; @@ -206,18 +162,9 @@ private static function resolve_block( array $block ): array { /** * Replaces a pattern block with the pattern's blocks, content written in. * - * A pattern block with no content of its own is still expanded when the - * pattern it points at reaches one that has some — otherwise core would - * flatten its way down to that pattern and drop the content. A pattern - * block that leads nowhere near any content is left for core, unless - * `compose()` asked for it to be inlined here instead. - * - * A reference to a synced pattern is never expanded: it is returned as - * written, content and all, so the editor renders it as an instance. - * * @param array $block A parsed `core/pattern` block. - * @return array[]|null The blocks that replace it, an empty array to drop - * it, or null to leave it to core's resolver. + * @return array[]|null The blocks that replace it, an empty array to drop it, or null + * to leave it to core's resolver. */ private static function expand_pattern_block( array $block ): ?array { $slug = $block['attrs']['slug'] ?? null; @@ -227,17 +174,9 @@ private static function expand_pattern_block( array $block ): ?array { if ( ! is_string( $slug ) || ! $registry->is_registered( $slug ) ) { return null; } - - /* - * Kept, not left to core: null would hand it to `resolve_pattern_blocks()`, - * which inlines it and drops the content, and that is exactly the copy - * a synced pattern must never become. - */ if ( Synced_Patterns::is_synced( $slug ) ) { return array( $block ); } - - // A pattern that contains itself is dropped, the way core drops it. if ( isset( self::$expanding[ $slug ] ) ) { return array(); } @@ -260,8 +199,6 @@ private static function expand_pattern_block( array $block ): ?array { self::$expanding[ $slug ] = true; $blocks = self::resolve_blocks( $blocks ); unset( self::$expanding[ $slug ] ); - - // Nothing inside needed this resolver, so core should expand it instead. if ( ! self::$inline_plain && ! $has_content && self::$expansions === $expansions ) { return null; } @@ -272,11 +209,7 @@ private static function expand_pattern_block( array $block ): ?array { /** * Marks a single-block pattern as an instance of that pattern. * - * Mirrors what core's `resolve_pattern_blocks()` does when it inlines a - * pattern, so a pattern expanded here still reads as a pattern instance in - * the editor. - * - * @param array[] $blocks The pattern's blocks. + * @param array[] $blocks The pattern's blocks. * @param array $pattern The registered pattern. * @return array[] The blocks. */ @@ -287,14 +220,7 @@ private static function add_pattern_metadata( array $blocks, array $pattern ): a $metadata = $blocks[0]['attrs']['metadata'] ?? array(); $metadata['patternName'] = $pattern['name']; - - /* - * A block's own name wins over the pattern's title, which is the one place - * this departs from core's resolver. A block that names a content slot has - * just had that slot filled, and renaming it would throw away what it was - * for. Core's editor makes the same choice when it expands a pattern. - */ - $values = array( + $values = array( 'name' => $metadata['name'] ?? $pattern['title'] ?? null, 'description' => $pattern['description'] ?? $metadata['description'] ?? null, 'categories' => $pattern['categories'] ?? $metadata['categories'] ?? null, @@ -318,12 +244,7 @@ private static function add_pattern_metadata( array $blocks, array $pattern ): a /** * Writes a pattern's content into that pattern's blocks. * - * Every `core/pattern-overrides` binding in the tree is removed afterwards, - * including the ones no value was supplied for. The composed blocks are no - * longer inside a pattern, so a binding left behind would resolve to - * nothing and would only make the block read-only in the editor. - * - * @param array[] $blocks The pattern's parsed blocks. + * @param array[] $blocks The pattern's parsed blocks. * @param array $content Content, keyed by slot name and then attribute name. * @return array[] The blocks with the content written into them. */ @@ -353,7 +274,7 @@ public static function apply_content( array $blocks, array $content ): array { /** * Writes values into one block's content slots and removes its bindings. * - * @param array $block A parsed block. + * @param array $block A parsed block. * @param array $values Values for this block, keyed by attribute name. * @return array The updated block. */ @@ -412,8 +333,6 @@ private static function get_supported_attributes( string $block_name ): array { if ( function_exists( 'get_block_bindings_supported_attributes' ) ) { return get_block_bindings_supported_attributes( $block_name ); } - - // WordPress 6.8 and earlier keep this list private to `WP_Block`. $supported = array( 'core/paragraph' => array( 'content' ), 'core/heading' => array( 'content' ), diff --git a/includes/class-safe-css.php b/includes/class-safe-css.php index 4e11879..f5fab61 100644 --- a/includes/class-safe-css.php +++ b/includes/class-safe-css.php @@ -15,80 +15,10 @@ /** * What a `css` property may contain, and why so little of it. - * - * A theme.json `styles` tree can hold a `css` property carrying literal CSS, - * and it is the only way a block style variation expresses a pseudo-element, a - * descendant rule or a hover state — which is most of what a variation is for. - * WordPress does not sanitize that property at all: core gates it on the - * `edit_css` capability instead and says so in a comment beside the check. So - * a string arriving over the wire cannot be trusted to core, and this is the - * check that decides whether one may be written, carried or installed. - * - * It is a **grammar**, not a filter. Nothing here strips, repairs or escapes: - * a string either fits the subset and is written through unchanged, or it is - * refused with the rule it broke and the fragment that broke it. Repairing is - * how a checker and a browser come to disagree, and a disagreement is the - * whole vulnerability. - * - * ### The shape - * - * Declarations first, then nested rules anchored on `&`: - * - * position: relative; overflow: hidden; - * & > * { z-index: 1; } - * &::before { content: ""; inset: 0; } - * - * That shape is not a style choice — it is the most core's own parser can - * read. `WP_Theme_JSON::process_blocks_custom_css()` splits the string on `&`, - * strips every `}`, then `explode( '{', … )` and skips any part that does not - * yield exactly two pieces. Everything this class accepts is a strict subset - * of what that method parses **correctly**, so that what was validated is what - * core emits. Three consequences a reader will otherwise find surprising: - * - * - A top-level declaration may not follow a nested rule. Core folds it into - * the rule above it (`&:hover { a } b: c;` emits `b: c` inside `:hover`), - * so the two would disagree about what was approved. - * - `&` may appear only as the character that opens a nested rule. Core splits - * on every one of them, including one inside a quoted string. - * - A nested rule may not contain another. Core drops the outer rule's body - * and promotes the inner one, silently. - * - * ### The bans - * - * `<` is refused everywhere, quoted strings included, because the HTML - * tokenizer ends a `