All plugins

JCORE Silta

Sync content between WordPress sites: posts, media, menus, custom fields and translations, with diff review and undo.

Silta ("bridge") syncs content between WordPress sites: single posts, whole post types, menus, media and Gravity Forms forms, together with their custom fields, terms, Polylang translations and the posts and media they refer to. URLs and custom rules are search-replaced on the way, a diff mode lets you pick section by section what to sync, and every sync can be undone. The main use case is staging → production.

This file covers the repository and the development workflow. What the plugin does for site owners is in readme.txt; the design decisions and their reasoning are in docs/design.md.

Requirements

  • PHP 8.2+ (with sodium, or WordPress's bundled fallback)
  • WordPress 6.7+
  • Node 22+ and pnpm
  • Composer
  • Docker with Compose, for the two-site development stack and the integration tests

Getting started

pnpm install
composer install
pnpm build
cd dev && docker compose up -d && ./setup.sh

That gives you two paired sites: staging on http://localhost:8090 and production on http://localhost:8091 (log in as admin / admin). Production starts as a clone of staging, and the two have drifted apart since, the way real ones do. See dev/README.md for what the seed creates and how to run WP-CLI on each site.

Gravity Forms in the dev stack (optional)

Gravity Forms is commercial, so the dev stack and the integration tests only get it when you have a license key. It is installed from Gravity Forms' Composer repository (composer.gravity.io, package gravity/gravityforms), which serves Elite and legacy Developer licenses only.

cp dev/.env.example dev/.env   # then set GRAVITY_FORMS_LICENSE_KEY (and GRAVITY_FORMS_SITE_URL if you like)
cd dev && ./reset.sh           # or ./setup.sh on an existing stack

dev/setup.sh runs dev/gravityforms/install.sh, which hands the key to Composer through COMPOSER_AUTH (HTTP basic auth: the key is the user name, GRAVITY_FORMS_SITE_URL the password, http://localhost:8090 by default) for that one command and installs into dev/gravityforms/plugins/. dev/.env, auth.json and everything installed there are git-ignored. Gravity Forms is a separate Composer project under dev/: the plugin's own composer.json doesn't know about it, so composer install and the release build never need the key, and .distignore keeps dev/ out of the zip. Without a key the install script prints a note and the stack runs without Gravity Forms.

In CI, add the license key as the repository secret GRAVITY_FORMS_LICENSE_KEY (Settings → Secrets and variables → Actions); optionally set the repository variable GRAVITY_FORMS_SITE_URL. The integration workflow passes both to tests/integration/run.sh. Pull requests from forks get no secrets, so there the Gravity Forms checks print skip and the rest of the run goes on.

For UI work on a single site, pnpm playground serves WordPress Playground on http://localhost:8887 from .wp/blueprint.json.

Scripts

CommandWhat it does
pnpm buildBuild the admin app and the block editor panel into build/.
pnpm startSame, in watch mode.
pnpm checkEverything CI lints: ESLint, Stylelint and PHPCS.
pnpm lint:js / lint:css / lint:phpOne linter at a time.
pnpm formatFormat src/ with wp-scripts format.
composer testPHPUnit unit tests (no WordPress needed).
composer lint:fixFix what PHPCBF can fix.
tests/integration/run.shTwo-site integration tests on a fresh dev stack (add --no-reset to reuse one).
pnpm i18nRegenerate the POT, the MO files and the JS translation JSON.
pnpm playgroundServe the plugin in WordPress Playground.

A Makefile wraps the same scripts (make ci is the entry point the shared publish workflow calls); it is a shim, not a second build system.

How it works

Every sync has a source (where content comes from) and a target, and an initiator (the site whose admin or CLI runs it) and a remote. A push makes the initiator the source; a pull makes it the target. Only the initiator makes requests, so a local or password-protected site can push to production and pull from it.

Initiator                                         Remote
─────────                                         ──────
Admin app / WP-CLI / list actions / editor panel
        │ jcore-silta/v1 (cookie + nonce, capability)
        ▼
Jobs\Runner ── Endpoint\Local                     Rest\Remote_Controller
        │                                         (Ed25519 signature + scope)
        └───── Endpoint\Remote ── signed HTTP ──▶ Endpoint\Local

Endpoint\Endpoint is the list of operations a site offers (export, plan, import, media transfer, undo…). The runner talks to a source endpoint and a target endpoint; one is Local, the other Remote, so push and pull share one code path.

A job goes through these steps, each run in time-boxed slices so the admin app can drive it request by request and WP-CLI in a loop:

  1. Resolve the selection (posts, whole post types, menus, forms) into job items.
  2. Export a batch on the source (Sync\Exporter): every object's state (Sync\State), the IDs it refers to (References\Walker), stubs for referenced objects, the users and ACF fields it mentions.
  3. Plan the batch on the target (Sync\Planner): match each object to a local one (Sync\Matcher: stored mapping → origin → GUID → file path → slug; forms: mapping → origin → same ID and title → title), transform it into local terms (Sync\Context: remap IDs, search-replace URLs and rules), compare section by section (Sync\Differ), detect conflicts and missing dependencies, check the target can take it (Sync\Preflight).
  4. Add the first level of missing dependencies and plan those; find orphans for whole post types.
  5. Review (admin app) or straight on (CLI): include/exclude items, confirm conflicts, pick sections in diff mode, override matches.
  6. Run: transfer media in chunks (Sync\Media), import in batches (Sync\Importer: terms, media, forms, posts, menus; dependencies first), snapshot everything changed (Sync\Snapshots), record mappings (Sync\Mapping), re-import items whose references were created later in the job, trash the chosen orphans.

Undo restores the snapshots, removes what the job created and untrashes what it trashed.

Layout

jcore-silta.php                Plugin header, constants, autoloader, bootstrap
uninstall.php                  Removes options, tables, cron, capability and temp files
includes/
  class-plugin.php             Wires every component to its hooks
  class-database.php           Tables and migrations
  class-site.php               Site UUID and clone detection
  class-settings.php           Settings option
  class-capabilities.php       jcore_silta_sync, granted to everyone with manage_options
  class-cron.php               Daily pruning of snapshots, job items and file transfers
  crypto/                      Identity (Ed25519 key pair), Vault (encryption at rest), Signature
  remote/                      Connections (outgoing), Trusted_Keys (incoming), Client, Verifier
  endpoint/                    Endpoint, Local, Remote
  jobs/                        Jobs (tables), Runner (drives a job)
  sync/                        Exporter, State, Planner, Importer, Context, Matcher, Differ,
                               Preflight, Replacer, Mapping, Snapshots, Media, Menus,
                               Meta_Filter, Meta_Writer, Acf, Polylang, Gravity_Forms, Content
  references/                  Walker, Blocks, Acf_Values, Meta_Keys, Values
  integrations/                Maailma (jcore-maailma global content), Gravity_Forms (form block,
                               shortcodes, confirmation pages)
  rest/                        REST controllers
  admin/                       Tools page, list table actions, editor panel loader, notices
  cli/                         `wp silta` commands
views/admin/page.php           Mount point for the admin app
src/admin/                     Admin app (sync, history, connections, settings)
src/editor/                    Block editor panel
dev/                           Two-site Docker stack (dev/gravityforms/: optional Gravity Forms install)
tests/unit/                    PHPUnit tests for the pure logic
tests/integration/run.sh       Two-site integration tests
docs/design.md                 Design decisions
languages/                     .po sources; .pot, .mo and .json are generated

Classes autoload from the Jcore\Silta namespace: Jcore\Silta\Sync\Meta_Filter lives in includes/sync/class-meta-filter.php.

REST API

Everything lives under jcore-silta/v1.

Admin app. Cookie authentication with the REST nonce. Routes marked sync need jcore_silta_sync; the rest need manage_options.

RouteMethodsCap
/sourcesGETsyncConnections to pick from
/sourceGETsyncPost types, menus, forms and languages of the source side, and whether the target has Gravity Forms
/source/postsGETsyncSearch the source side's posts
/jobsGET, POSTsyncHistory; create a job
/jobs/{id}GET, DELETEsyncProgress; drop a job that never ran
/jobs/{id}/prepare, /expand, /run, /undo, /cancelPOSTsyncDrive a job
/jobs/{id}/itemsGET, PATCHsyncReview items; bulk changes
/jobs/{id}/items/{item}PATCHsyncInclude, confirm, choose sections, override the match
/jobs/{id}/items/{item}/diffGETsyncSection-by-section diff
/status/{post}GETsync + edit_postA post's state on every site this one can push to
/connectionsGET, POSTadminOverview; pair with a remote
/connections/{id}PATCH, DELETEadminLabel, basic auth, saved rules; remove
/connections/{id}/verifyPOSTadminFresh handshake
/keys/tokensPOSTadminCreate a pairing string
/keys/tokens/{id}DELETEadminCancel a pairing string
/keys/{fingerprint}PATCH, DELETEadminChange or revoke a paired site
/identity/export, /identity/regeneratePOSTadminThe wp-config.php line; a new key pair
/clonedDELETEadminDismiss the clone notice
/settingsGET, POSTadminSettings

Between sites. Every route is POST and signed with the initiator's Ed25519 key (X-Silta-Key, -Timestamp, -Nonce, -Signature over silta-v1 \n remote UUID \n method \n route \n timestamp \n nonce \n sha256(body)), except pair, which takes a one-time pairing token. The remote acts as the admin who created the token.

RouteScope
/remote/pairpairing token
/remote/handshake, /remote/statusany
/remote/query, /remote/ids, /remote/export, /remote/media/readread
/remote/plan, /remote/diff, /remote/media/prepare, /remote/media/write, /remote/import, /remote/orphans, /remote/undowrite

Database tables

TableHolds
{prefix}jcore_silta_mapWhich local object a remote one was synced to (post, term or form), with hashes of what was received and written (for conflict detection)
{prefix}jcore_silta_jobsJobs: role (initiator/target), direction, connection, status, options, state, summary
{prefix}jcore_silta_job_itemsPer object: plan, payload, choices and result (pruned with old jobs)
{prefix}jcore_silta_snapshotsState before a job changed something, for undo (pruned after the configured days)
{prefix}jcore_silta_noncesRequest nonces already seen, for replay protection (pruned daily)
{prefix}jcore_silta_transfersOpen media transfers and their manifests (expire after a day)

Options: jcore_silta_settings, jcore_silta_site, jcore_silta_identity, jcore_silta_connections, jcore_silta_keys, jcore_silta_cloned, jcore_silta_db_version. Schema changes go through Database::maybe_upgrade() with a bumped DB_VERSION.

Hooks

HookType
jcore_silta_meta_referencesfilterMeta keys that hold IDs: array( '<key or prefix*>' => 'post'|'term'|'user'|'form' ) (form: a Gravity Forms form ID)
jcore_silta_block_referencesfilterBlock attributes that hold IDs: array( 'jco/hero' => array( 'imageId' => 'post', 'posts.*' => 'post' ) ). A top-level attribute holding a post's slug maps to 'slug:<post type>'
jcore_silta_excluded_metafilterMeta keys never synced (a trailing * matches a prefix)
jcore_silta_post_typesfilterPost types offered in the picker
jcore_silta_syncable_post_type / _taxonomy / _statusesfilterWhether a post type, taxonomy or post status may be exported and imported at all
jcore_silta_allow_insecurefilterWhether a remote may use plain http (default: only on local/development environments)
jcore_silta_allow_private_networkfilterWhether a remote may be on a loopback or private address (default: the same as jcore_silta_allow_insecure)
jcore_silta_max_request_bytes, _max_response_bytes, _max_export_keys, _max_request_objectsfilterSize limits of signed requests and responses (8 MB, 32 MB, 100, 100)
jcore_silta_media_max_files, _media_max_bytes, _media_max_open_transfersfilterMedia transfer limits (256 files, 5 GB, 10,000 open per site)
jcore_silta_remote_urlfilterRewrite the URL of a request to a remote (the dev stack maps localhost ports to container names)
jcore_silta_clone_detectedactionFires when Silta notices it runs on a copy of another site's database

ACF fields (image, file, gallery, post object, relationship, page link, taxonomy, user, the "ACF: Gravity Forms" forms field, also inside repeaters, flexible content, groups, clones and ACF blocks), core blocks, featured images, parents, terms, Polylang translations, menu items, the Yoast / Rank Math / SEOPress image and primary-term keys, and Gravity Forms embeds (the gravityforms/form block, the [gravityform] shortcode in content and plain-text meta, and confirmation pages) are handled without configuration.

jcore-maailma global content is supported by Integrations\Maailma: it declares the jco/global-content block's selectedPostId and selectedSlug through jcore_silta_block_references, and keeps Maailma from renaming a global content post's slug after its title while Silta writes the post (Sync\Importer::writing()).

Gravity Forms embeds are handled by Integrations\Gravity_Forms: it declares the gravityforms/form block's formId through jcore_silta_block_references, and remaps the [gravityform] shortcode in content and plain-text meta and the pages confirmations redirect to. The forms themselves are a sync kind of their own (Sync\Gravity_Forms, through GFAPI).

WP-CLI

wp silta keys create-token --label=Staging --scopes=read,write --user=admin   # on the remote
wp silta connections add <pairing string> --user=admin                          # on the initiator
wp silta push production --post=12,34 --dry-run
wp silta push production --post-type=event --trash-orphans --yes
wp silta push production --form=3                                               # Gravity Forms, on both sites
wp silta pull production --post=56 --status=draft
wp silta diff production --post=12
wp silta jobs
wp silta undo <job id> --yes
wp silta key info | export | regenerate

wp help silta lists every command and option.

Testing

  • composer test runs the PHPUnit unit tests for the logic that needs no database: the serialization-safe replacer, ID remapping in blocks, ACF values and meta, jcore-maailma's block references, Gravity Forms blocks, shortcodes, confirmations, form matching and normalization, and request signatures.
  • tests/integration/run.sh resets the dev stack and runs real syncs between the two sites: pairing, unsigned requests, dry runs, conflicts, dependencies and remapping, translations, undo, block content, media, pull, menus, whole post types with orphans, jcore-maailma global content, Gravity Forms (when installed, see above: forms as dependencies, block and shortcode IDs remapped, URLs replaced, update and undo), and clone detection. CI runs it in .github/workflows/integration.yml. To run it next to an existing dev stack without touching its data, give it another project name: COMPOSE_PROJECT_NAME=silta-it tests/integration/run.sh (the ports are the same, so stop the other stack first).

Releasing

Pushing to main runs .github/workflows/release.yml:

  1. check – lint, unit tests, build, then run Plugin Check against the tree minus .distignore. Also runs on pull requests.
  2. release – foonver reads the conventional commits since the last tag, bumps the version, syncs it into jcore-silta.php, readme.txt and package.json, writes the == Changelog == section of readme.txt and pushes the tag.
  3. publish – the reusable workflow in jcore-update builds the zip, attaches it to a GitHub release, registers the version with update.jcore.fi, pushes to the dist repository and posts to Slack.

Every commit that reaches main produces a release, so work on branches and merge through pull requests. Commit messages must follow Conventional Commits.

.distignore is the single list of what does not ship.

License

GPL-2.0-or-later. See LICENSE.