Skip to content

Commit f0703c1

Browse files
authored
Init
1 parent c82640c commit f0703c1

File tree

7 files changed

+671
-2
lines changed

7 files changed

+671
-2
lines changed

README.md

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,78 @@
1-
# gutenberg-patterns-sync
2-
Export and import block patterns (wp_block) as JSON via WP-CLI. Dev‑oriented to sync local patterns with other environments (staging, production).
1+
# Gutenberg Patterns Sync (Export/Import)
2+
3+
Dev‑oriented plugin to sync patterns from local with other environments (staging, production) by exporting and importing block patterns (wp_block) as JSON via WP‑CLI.
4+
5+
By default, stores these JSON files in the current theme's patterns/blocks-sync directory. It checks existing patterns to handle create/updates instead of hard‑deleting each time, which helps keep references in the DB and code stable.
6+
7+
Also, for each pattern, it keeps synced (or not) status, block categories.
8+
9+
## Requirements
10+
- WordPress 6.0+
11+
- PHP 7.4+
12+
- WP-CLI available in the environment
13+
14+
## Installation
15+
16+
### As a regular plugin
17+
18+
This package is a regular WordPress plugin. You can install and activate it like any other plugin.
19+
20+
- Copy this folder into `wp-content/plugins/gutenberg-patterns-sync` on your site.
21+
- In WP Admin → Plugins, activate “Gutenberg Patterns Sync”.
22+
23+
### Via Composer (as MU‑plugin)
24+
25+
You can install by Composer, it is designed to be installed as a MU plugin under `wp-content/mu-plugins/` (developer‑oriented).
26+
27+
#### From Packagist
28+
29+
Do `composer require maximeculea/gutenberg-patterns-sync`
30+
31+
#### From Github
32+
33+
- Add into your composer.json `{ "type": "vcs", "url": "https://github.com/MaximeCulea/gutenberg-patterns-sync" }`
34+
- Include `"maximeculea/gutenberg-patterns-sync": "dev-master"` in your composer file as require
35+
- Before use, launch `composer update`
36+
37+
## Commands
38+
39+
- `wp patterns export` — Export all published patterns (`wp_block` posts) to JSON files.
40+
- `wp patterns import` — Import patterns (`wp_block` posts) from JSON files and optionally delete missing ones.
41+
42+
### Export
43+
```
44+
wp patterns export [--dir=<path>] [--no-pretty] [--prefix=<slug-prefix>]
45+
```
46+
Options:
47+
- `--dir=<path>`: Destination directory. Default is current theme: `<theme>/patterns/blocks-sync`.
48+
- `--no-pretty`: Disable pretty-printed JSON (pretty-print is enabled by default).
49+
- `--prefix=<slug-prefix>`: Prefix for the `slug` field inside JSON (e.g. `mytheme`). Defaults to the active theme textdomain.
50+
51+
Output files are named `<slug>.json` and contain:
52+
```
53+
{
54+
"title": "...",
55+
"slug": "<prefix>/<slug>",
56+
"content": "...",
57+
"categories": ["..."],
58+
"syncStatus": "synced" | "unsynced"
59+
}
60+
```
61+
62+
### Import
63+
```
64+
wp patterns import [--dir=<path>] [--dry-run] [--no-verbose]
65+
```
66+
Options:
67+
- `--dir=<path>`: Source directory. Default is current theme: `<theme>/patterns/blocks-sync`.
68+
- `--dry-run`: Show actions without applying changes.
69+
- `--no-verbose`: Reduce log noise.
70+
71+
Behavior:
72+
- Creates or updates by slug.
73+
- Assigns `wp_pattern_category` terms (creates them if missing).
74+
- Preserves sync status (`wp_pattern_sync_status` post meta).
75+
- Deletes existing `wp_block` posts not present in the source directory (unless `--dry-run` is used).
76+
77+
## License
78+
© Maxime Culea. Gutenberg Patterns Sync is licensed under GPL-3.0+.

autoload.php

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<?php namespace MC\WP_CLI_Commands;
2+
3+
/**
4+
* An example of a general-purpose implementation that includes the optional
5+
* functionality of allowing multiple base directories for a single namespace
6+
* prefix.
7+
*
8+
* Given a foo-bar package of classes in the file system at the following
9+
* paths ...
10+
*
11+
* /path/to/packages/foo-bar/
12+
* src/
13+
* Baz.php # Foo\Bar\Baz
14+
* Qux/
15+
* Quux.php # Foo\Bar\Qux\Quux
16+
* tests/
17+
* BazTest.php # Foo\Bar\BazTest
18+
* Qux/
19+
* QuuxTest.php # Foo\Bar\Qux\QuuxTest
20+
*
21+
* ... add the path to the class files for the \Foo\Bar\ namespace prefix
22+
* as follows:
23+
*
24+
* <?php
25+
* // instantiate the loader
26+
* $loader = new \Example\Psr4AutoloaderClass;
27+
*
28+
* // register the autoloader
29+
* $loader->register();
30+
*
31+
* // register the base directories for the namespace prefix
32+
* $loader->addNamespace('Foo\Bar', '/path/to/packages/foo-bar/src');
33+
* $loader->addNamespace('Foo\Bar', '/path/to/packages/foo-bar/tests');
34+
*
35+
* The following line would cause the autoloader to attempt to load the
36+
* \Foo\Bar\Qux\Quux class from /path/to/packages/foo-bar/src/Qux/Quux.php:
37+
*
38+
* <?php
39+
* new \Foo\Bar\Qux\Quux;
40+
*
41+
* The following line would cause the autoloader to attempt to load the
42+
* \Foo\Bar\Qux\QuuxTest class from /path/to/packages/foo-bar/tests/Qux/QuuxTest.php:
43+
*
44+
* <?php
45+
* new \Foo\Bar\Qux\QuuxTest;
46+
*/
47+
class Autoloader {
48+
/**
49+
* An associative array where the key is a namespace prefix and the value
50+
* is an array of base directories for classes in that namespace.
51+
*
52+
* @var array
53+
*/
54+
protected $prefixes = [];
55+
56+
/**
57+
* Register loader with SPL autoloader stack.
58+
*
59+
* @return void
60+
*/
61+
public function register() {
62+
spl_autoload_register( [ $this, 'loadClass' ] );
63+
}
64+
65+
/**
66+
* Adds a base directory for a namespace prefix.
67+
*
68+
* @param string $prefix The namespace prefix.
69+
* @param string $base_dir A base directory for class files in the
70+
* namespace.
71+
* @param bool $prepend If true, prepend the base directory to the stack
72+
* instead of appending it; this causes it to be searched first rather
73+
* than last.
74+
*
75+
* @return void
76+
*/
77+
public function addNamespace( $prefix, $base_dir, $prepend = false ) {
78+
// normalize namespace prefix
79+
$prefix = trim( $prefix, '\\' ) . '\\';
80+
81+
// normalize the base directory with a trailing separator
82+
$base_dir = rtrim( $base_dir, DIRECTORY_SEPARATOR ) . '/';
83+
84+
// initialize the namespace prefix array
85+
if ( isset( $this->prefixes[ $prefix ] ) === false ) {
86+
$this->prefixes[ $prefix ] = [];
87+
}
88+
89+
// retain the base directory for the namespace prefix
90+
if ( $prepend ) {
91+
array_unshift( $this->prefixes[ $prefix ], $base_dir );
92+
} else {
93+
array_push( $this->prefixes[ $prefix ], $base_dir );
94+
}
95+
}
96+
97+
/**
98+
* Loads the class file for a given class name.
99+
*
100+
* @param string $class The fully-qualified class name.
101+
*
102+
* @return mixed The mapped file name on success, or boolean false on
103+
* failure.
104+
*/
105+
public function loadClass( $class ) {
106+
// the current namespace prefix
107+
$prefix = $class;
108+
109+
// work backwards through the namespace names of the fully-qualified
110+
// class name to find a mapped file name
111+
while( false !== $pos = strrpos( $prefix, '\\' ) ) {
112+
113+
// retain the trailing namespace separator in the prefix
114+
$prefix = substr( $class, 0, $pos + 1 );
115+
116+
// the rest is the relative class name
117+
$relative_class = substr( $class, $pos + 1 );
118+
119+
// try to load a mapped file for the prefix and relative class
120+
$mapped_file = $this->loadMappedFile( $prefix, $relative_class );
121+
if ( $mapped_file ) {
122+
return $mapped_file;
123+
}
124+
125+
// remove the trailing namespace separator for the next iteration
126+
// of strrpos()
127+
$prefix = rtrim( $prefix, '\\' );
128+
}
129+
130+
// never found a mapped file
131+
return false;
132+
}
133+
134+
/**
135+
* Load the mapped file for a namespace prefix and relative class.
136+
*
137+
* @param string $prefix The namespace prefix.
138+
* @param string $relative_class The relative class name.
139+
*
140+
* @return mixed Boolean false if no mapped file can be loaded, or the
141+
* name of the mapped file that was loaded.
142+
*/
143+
protected function loadMappedFile( $prefix, $relative_class ) {
144+
// are there any base directories for this namespace prefix?
145+
if ( isset( $this->prefixes[ $prefix ] ) === false ) {
146+
return false;
147+
}
148+
149+
// look through base directories for this namespace prefix
150+
foreach ( $this->prefixes[ $prefix ] as $base_dir ) {
151+
152+
// replace the namespace prefix with the base directory,
153+
// replace namespace separators with directory separators
154+
// in the relative class name, append with .php
155+
$file = $base_dir . strtolower( str_replace( [ '\\', '_' ], [
156+
'/',
157+
'-'
158+
], $relative_class ) ) . '.php';
159+
160+
// if the mapped file exists, require it
161+
if ( $this->requireFile( $file ) ) {
162+
// yes, we're done
163+
return $file;
164+
}
165+
}
166+
167+
// never found it
168+
return false;
169+
}
170+
171+
/**
172+
* If a file exists, require it from the file system.
173+
*
174+
* @param string $file The file to require.
175+
*
176+
* @return bool True if the file exists, false if not.
177+
*/
178+
protected function requireFile( $file ) {
179+
if ( file_exists( $file ) ) {
180+
require $file;
181+
182+
return true;
183+
}
184+
185+
return false;
186+
}
187+
}
188+
189+
// instantiate the loader
190+
$loader = new \MC\WP_CLI_Commands\Autoloader;
191+
192+
// register the autoloader
193+
$loader->register();
194+
195+
// register the base directories for the namespace prefix
196+
$loader->addNamespace( 'MC\WP_CLI_Commands', MC_PATTERNS_CLI_DIR . 'classes' );

classes/patterns-export.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php namespace MC\WP_CLI_Commands;
2+
3+
class Patterns_Export extends
4+
\WP_CLI_Command {
5+
/**
6+
* Export all wp_block (synced patterns) to JSON files (one per block).
7+
*
8+
* ## OPTIONS
9+
* [--dir=<path>]
10+
* : Destination directory. Default: current theme's patterns/blocks-sync
11+
*
12+
* [--pretty]
13+
* : Pretty-print JSON. Default: true
14+
*
15+
* [--prefix=<slug_prefix>]
16+
* : Slug prefix to include in exported JSON slug field. Default: theme textdomain
17+
*
18+
* ## EXAMPLES
19+
* wp patterns export
20+
* wp patterns export --dir=<path> --no-pretty --prefix=<slug_prefix>
21+
*/
22+
public function export( $args, $assoc_args ) {
23+
$blocks_dir = isset( $assoc_args[ 'dir' ] ) ? (string) $assoc_args[ 'dir' ] : get_theme_file_path( '/patterns/blocks-sync' );
24+
$pretty = ! isset( $assoc_args[ 'no-pretty' ] );
25+
$prefix = isset( $assoc_args[ 'prefix' ] ) ? (string) $assoc_args[ 'prefix' ] : ( function_exists( 'wp_get_theme' ) ? wp_get_theme()->get( 'TextDomain' ) : '' );
26+
27+
if ( ! is_dir( $blocks_dir ) && ! wp_mkdir_p( $blocks_dir ) ) {
28+
\WP_CLI::error( "Cannot create blocks-dir: {$blocks_dir}" );
29+
}
30+
31+
$taxonomy = 'wp_pattern_category';
32+
if ( ! taxonomy_exists( $taxonomy ) ) {
33+
register_taxonomy( $taxonomy, 'wp_block', [ 'public' => false, 'show_ui' => false ] );
34+
}
35+
36+
$q = new \WP_Query( [
37+
'post_type' => 'wp_block',
38+
'posts_per_page' => - 1,
39+
'orderby' => 'name',
40+
'order' => 'ASC',
41+
'post_status' => 'publish',
42+
] );
43+
44+
$flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | ( $pretty ? JSON_PRETTY_PRINT : 0 );
45+
$count = 0;
46+
47+
foreach ( $q->posts as $post ) {
48+
$title = get_the_title( $post );
49+
$slug = ! empty( $prefix ) ? sprintf( '%s/%s', $prefix, sanitize_title( $title ) ) : sanitize_title( $title );
50+
$content = get_post_field( 'post_content', $post );
51+
52+
$terms = get_the_terms( $post, $taxonomy );
53+
$cats = [];
54+
if ( is_array( $terms ) ) {
55+
foreach ( $terms as $t ) {
56+
$cats[] = $t->name;
57+
}
58+
$cats = array_values( array_unique( $cats ) );
59+
}
60+
61+
$data = [
62+
'title' => $title,
63+
'slug' => $slug,
64+
'content' => (string) $content,
65+
'categories' => $cats,
66+
'syncStatus' => get_post_meta( $post->ID, 'wp_pattern_sync_status', true ) ?: 'synced',
67+
];
68+
69+
$json = json_encode( $data, $flags );
70+
if ( $json === false ) {
71+
\WP_CLI::warning( "JSON encode failed for {$slug}" );
72+
continue;
73+
}
74+
75+
$file = rtrim( $blocks_dir, '/' ) . sprintf( '/%s.json', sanitize_title( $title ) );
76+
if ( file_put_contents( $file, $json ) === false ) {
77+
\WP_CLI::warning( "Failed to write {$file}" );
78+
continue;
79+
}
80+
81+
\WP_CLI::log( "[OK] {$slug} -> {$file}" );
82+
$count ++;
83+
}
84+
85+
\WP_CLI::success( "Exported {$count} block(s) to {$blocks_dir}" );
86+
}
87+
}

0 commit comments

Comments
 (0)