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
| Command | What it does |
|---|---|
pnpm build | Build the admin app and the block editor panel into build/. |
pnpm start | Same, in watch mode. |
pnpm check | Everything CI lints: ESLint, Stylelint and PHPCS. |
pnpm lint:js / lint:css / lint:php | One linter at a time. |
pnpm format | Format src/ with wp-scripts format. |
composer test | PHPUnit unit tests (no WordPress needed). |
composer lint:fix | Fix what PHPCBF can fix. |
tests/integration/run.sh | Two-site integration tests on a fresh dev stack (add --no-reset to reuse one). |
pnpm i18n | Regenerate the POT, the MO files and the JS translation JSON. |
pnpm playground | Serve 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:
- Resolve the selection (posts, whole post types, menus, forms) into job items.
- 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. - 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). - Add the first level of missing dependencies and plan those; find orphans for whole post types.
- Review (admin app) or straight on (CLI): include/exclude items, confirm conflicts, pick sections in diff mode, override matches.
- 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.
| Route | Methods | Cap | |
|---|---|---|---|
/sources | GET | sync | Connections to pick from |
/source | GET | sync | Post types, menus, forms and languages of the source side, and whether the target has Gravity Forms |
/source/posts | GET | sync | Search the source side's posts |
/jobs | GET, POST | sync | History; create a job |
/jobs/{id} | GET, DELETE | sync | Progress; drop a job that never ran |
/jobs/{id}/prepare, /expand, /run, /undo, /cancel | POST | sync | Drive a job |
/jobs/{id}/items | GET, PATCH | sync | Review items; bulk changes |
/jobs/{id}/items/{item} | PATCH | sync | Include, confirm, choose sections, override the match |
/jobs/{id}/items/{item}/diff | GET | sync | Section-by-section diff |
/status/{post} | GET | sync + edit_post | A post's state on every site this one can push to |
/connections | GET, POST | admin | Overview; pair with a remote |
/connections/{id} | PATCH, DELETE | admin | Label, basic auth, saved rules; remove |
/connections/{id}/verify | POST | admin | Fresh handshake |
/keys/tokens | POST | admin | Create a pairing string |
/keys/tokens/{id} | DELETE | admin | Cancel a pairing string |
/keys/{fingerprint} | PATCH, DELETE | admin | Change or revoke a paired site |
/identity/export, /identity/regenerate | POST | admin | The wp-config.php line; a new key pair |
/cloned | DELETE | admin | Dismiss the clone notice |
/settings | GET, POST | admin | Settings |
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.
| Route | Scope |
|---|---|
/remote/pair | pairing token |
/remote/handshake, /remote/status | any |
/remote/query, /remote/ids, /remote/export, /remote/media/read | read |
/remote/plan, /remote/diff, /remote/media/prepare, /remote/media/write, /remote/import, /remote/orphans, /remote/undo | write |
Database tables
| Table | Holds |
|---|---|
{prefix}jcore_silta_map | Which 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_jobs | Jobs: role (initiator/target), direction, connection, status, options, state, summary |
{prefix}jcore_silta_job_items | Per object: plan, payload, choices and result (pruned with old jobs) |
{prefix}jcore_silta_snapshots | State before a job changed something, for undo (pruned after the configured days) |
{prefix}jcore_silta_nonces | Request nonces already seen, for replay protection (pruned daily) |
{prefix}jcore_silta_transfers | Open 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
| Hook | Type | |
|---|---|---|
jcore_silta_meta_references | filter | Meta keys that hold IDs: array( '<key or prefix*>' => 'post'|'term'|'user'|'form' ) (form: a Gravity Forms form ID) |
jcore_silta_block_references | filter | Block 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_meta | filter | Meta keys never synced (a trailing * matches a prefix) |
jcore_silta_post_types | filter | Post types offered in the picker |
jcore_silta_syncable_post_type / _taxonomy / _statuses | filter | Whether a post type, taxonomy or post status may be exported and imported at all |
jcore_silta_allow_insecure | filter | Whether a remote may use plain http (default: only on local/development environments) |
jcore_silta_allow_private_network | filter | Whether 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_objects | filter | Size limits of signed requests and responses (8 MB, 32 MB, 100, 100) |
jcore_silta_media_max_files, _media_max_bytes, _media_max_open_transfers | filter | Media transfer limits (256 files, 5 GB, 10,000 open per site) |
jcore_silta_remote_url | filter | Rewrite the URL of a request to a remote (the dev stack maps localhost ports to container names) |
jcore_silta_clone_detected | action | Fires 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 testruns 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.shresets 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:
- check – lint, unit tests, build, then run Plugin Check against the tree minus
.distignore. Also runs on pull requests. - release – foonver reads the conventional commits since the last tag, bumps the version, syncs it into
jcore-silta.php,readme.txtandpackage.json, writes the== Changelog ==section ofreadme.txtand pushes the tag. - 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.