hipdevteam / conversion-bridge
Secure REST endpoints for creating and updating WordPress pages, Elementor Theme Builder templates, kit globals, Custom Fonts, nav menus and the static front page.
Package info
gitlab.com/hipdevteam/conversion-bridge
Type:wordpress-plugin
pkg:composer/hipdevteam/conversion-bridge
Requires
- php: ^8.1
- composer/installers: ^1.0 || ^2.0
Requires (Dev)
This package is auto-updated.
Last update: 2026-08-20 20:44:36 UTC
README
A write-side REST API for shipping a converted website into a customer's WordPress + Elementor install — pages, Theme Builder templates, kit globals, custom fonts, nav menus, and the static front page.
| Version | 1.11.0 |
| Requires | WordPress 6.0+ · PHP 8.1+ |
| Optional | Elementor 3.x (kit globals) · Elementor Pro (Theme Builder, Custom Fonts) |
| REST namespace | conversion-bridge/v1 |
| License | GPL-2.0-or-later |
| Footprint | No admin UI · no options · no custom tables · no outbound requests |
Why this plugin exists
Most Elementor installs expose elementor/v1/import-export-customization/*, and a client can
just POST a kit ZIP at it. Elementor One / Elementor Cloud variants — and installs with the
experiment disabled — never register that namespace, so the standard import path 404s.
This plugin is the fallback: it writes the same post meta the official importer would write, but through plain WordPress APIs, so it works everywhere. Every class here exists to close one specific gap in that path.
Architecture at a glance
conversion-bridge.php Bootstrap: guards PHP 8.1, defines constants, requires includes
├── includes/class-cb-plugin.php Singleton · wires init + rest_api_init + upload filters
├── includes/class-cb-rest-controller.php The ONLY entry point · 5 routes · thin handlers
├── includes/class-cb-template-repository.php Pages/posts/templates → _elementor_data + meta
├── includes/class-cb-css.php Shared guard: strips `</style` from inbound CSS
├── includes/class-cb-kit-applier.php Kit globals → active kit's _elementor_page_settings
├── includes/class-cb-fonts-applier.php @font-face → elementor_font CPT (Pro)
├── includes/class-cb-menu-applier.php {name, items[]} → nav_menu term + nav_menu_item posts
└── includes/class-cb-uploads.php Unlocks SVG + web-font uploads (unfiltered_html)
flowchart LR
C[API client] -->|App Password| R[Rest_Controller]
R -->|/upsert| TR[Template_Repository]
R -->|/kit-apply| KA[Kit_Applier]
R -->|/kit-apply custom_fonts| FA[Fonts_Applier]
R -->|/menu-apply| MA[Menu_Applier]
R -->|/front-page| FP[wp options]
C -->|/wp/v2/media| U[Uploads filters]
TR --> WP[(post meta)]
KA --> WP
FA --> WP
MA --> TX[(nav_menu terms)]
FP --> OPT[(wp_options)]
One rule holds throughout: the controller never contains business logic. Each handler
validates shape, delegates to one applier, and maps WP_Error → HTTP status.
1. Feature Index
1.1 REST endpoints
| Endpoint | Method | Capability | Handler → Class | Since |
|---|---|---|---|---|
/ping | GET | manage_options | inline | 1.0.0 |
/upsert | POST | publish_pages | Template_Repository | 1.0.0 |
/kit-apply | POST | manage_options | Kit_Applier + Fonts_Applier | 1.4.0 |
/menu-apply | POST | manage_options | Menu_Applier | 1.6.0 |
/front-page | POST | manage_options | inline | 1.9.0 |
Base URL: https://<site>/wp-json/conversion-bridge/v1
1.2 Content types accepted by /upsert
Defined in TYPE_MAP — 10 logical types
over 3 post types:
type | post_type | _elementor_template_type | Needs Pro |
|---|---|---|---|
page | page | — | No |
post | post | — | No |
header | elementor_library | header | Yes |
footer | elementor_library | footer | Yes |
section | elementor_library | section | No |
megamenu | elementor_library | mega-menu | Yes |
single | elementor_library | single | Yes |
archive | elementor_library | archive | Yes |
popup | elementor_library | popup | Yes |
error_404 | elementor_library | error_404 | Yes |
The list is filterable — see §4 Extensibility.
1.3 Behavioral features
| Feature | Where | What it does |
|---|---|---|
| Slug-keyed idempotency | find_existing_post() | Re-running the same payload updates instead of duplicating. Library types additionally match on _elementor_template_type, so a header and a footer may share a slug. |
| Cross-type slug dedup | upsert() | A URL reclassified page↔post between runs leaves a stale post of the old type. It is trashed (core appends __trashed to the slug, freeing the permalink for the new type) and only when the caller has delete_post on it. Scoped to page/post only — never touches library templates. |
| Theme Builder cache regen | clear_caches() | Regenerates Elementor Pro's Conditions_Manager cache. Without it a REST-uploaded header exists but never matches a front-end request — it looks like the template silently failed to publish. The editor never hits this because it calls save_conditions() inline. |
_wp_page_template reset | write_meta() | '', default, and page.php all delete the meta, so a stale elementor_canvas from a prior run can't keep suppressing the global header/footer. Any other value is written verbatim. Pages only. |
| Request ceilings | upsert() · Menu_Applier::apply() | Encoded elementor_data over 8 MiB, or more than 500 menu items, returns 413 before anything is written. Both are filterable; /menu-apply does one post insert per item, so an unbounded list could time out with the menu already purged. |
| Kit allow-list | ALLOWED_KEYS | 78 permitted setting keys (palettes, breakpoints, body/h1–h6/link/button theme styles, custom_css). Everything else is silently dropped so a malformed payload can't corrupt unrelated kit settings. |
| Non-destructive kit merge | apply() | Supplied keys overwrite; unsupplied keys survive. Palette arrays are replaced wholesale — the palette is the unit of ownership, never merged element-by-element. |
| Managed-row marker | _cb_managed | Post meta on fonts, term meta on menus. Lets re-syncs recognize rows this plugin created — and, just as importantly, recognize rows it did not. |
| Menu collision guard | apply() | A same-named menu that is not marked managed returns 409 rather than being overwritten. Hand-built menus are safe. |
| Menu hierarchy | apply() | Each item may carry parent: an index into the same items array. Parents must precede children. Yields real dropdown sub-menus. |
| Fonts degrade inline | handle_kit_apply() | If the kit step succeeded but fonts failed (no Pro), the response still returns 200 with the error nested under fonts.error — globals still get credited. Fonts-only requests return the error directly. |
| Front-page auto-publish | handle_front_page() | A draft page is published before being promoted, so the site root never resolves to nothing. |
| SVG + web-font uploads | Uploads | Unlocks svg svgz woff woff2 ttf otf eot on /wp/v2/media and wp-admin. Needs two filters: upload_mimes to allow-list, plus wp_check_filetype_and_ext to stop the finfo real-MIME check from vetoing fonts (application/octet-stream) and SVGs (text/plain). Gated on unfiltered_html — the capability that already governs "may this user put script on the page", and which correctly excludes Multisite site admins and sites defining DISALLOW_UNFILTERED_HTML. |
1.4 Capability advertisement
/ping reports what the target can actually do, so a client can branch before sending work:
| Flag | Detected via |
|---|---|
kit_apply | always true (plugin is active) |
fonts_apply | post_type_exists('elementor_font') → Elementor Pro present |
menu_apply | wp_create_nav_menu + wp_update_nav_menu_item exist |
2. Dependency Index
2.1 Hard requirements
| Dependency | Version | Enforced where |
|---|---|---|
| PHP | ≥ 8.1 | Runtime guard in conversion-bridge.php:34:41 — prints an admin notice and returns instead of fatal-ing |
| WordPress | ≥ 6.0 | Header only |
composer/installers | ^1.0 \|\| ^2.0 | composer.json — routes the package into wp-content/plugins/ |
PHP language features in use: declare(strict_types=1), constructor promotion-free singletons,
??=, union return types (array|WP_Error), match(true), readonly-style private const.
2.2 Optional third-party — all degrade gracefully
| Dependency | Needed for | Absence behavior |
|---|---|---|
\Elementor\Plugin | /kit-apply, CSS cache clear | /kit-apply → 412 cb_elementor_missing. /upsert still works — it only writes meta. |
\Elementor\Plugin::$instance->kits_manager | Resolving the active kit | Falls back to the elementor_active_kit option (stable since Elementor 3.0) |
\Elementor\Plugin::$instance->files_manager | Clearing compiled CSS | Wrapped in try/catch — cache clearing is an optimization, not correctness |
elementor_font CPT (Pro) | Custom Fonts | 412 cb_elementor_pro_missing, or inline fonts.error when bundled with a kit apply |
\ElementorPro\...\ThemeBuilder\Module | Conditions cache regen | try/catch + method_exists guards; falls back to the generic cache clear |
Every Elementor touch-point is behind class_exists / isset / method_exists and a
try/catch. The plugin never hard-fails because a third-party API moved.
2.3 WordPress core APIs consumed
Full list of core functions used (click to expand)
**REST** — `register_rest_route`, `WP_REST_Request`, `WP_REST_Response`, `WP_REST_Server`, `WP_Error`, `is_wp_error` **Posts** — `wp_insert_post`, `wp_update_post`, `wp_delete_post`, `get_post`, `get_posts`, `get_post_type`, `get_permalink`, `get_edit_post_link`, `clean_post_cache`, `WP_Query`, `post_type_exists` **Meta** — `update_post_meta`, `get_post_meta`, `delete_post_meta`, `update_term_meta`, `get_term_meta` **Options** — `get_option`, `update_option` **Nav menus** — `wp_create_nav_menu`, `wp_update_nav_menu_item`, `wp_get_nav_menu_object`, `wp_get_nav_menu_items` **Sanitizing** — `sanitize_text_field`, `sanitize_title`, `sanitize_key`, `wp_slash`, `wp_json_encode` **i18n** — `__`, `esc_html__`, `load_plugin_textdomain` **Auth** — `current_user_can` **Plumbing** — `add_action`, `add_filter`, `apply_filters`, `do_action`, `plugin_dir_path`, `plugin_basename`, `get_bloginfo`2.4 Persistence index — everything this plugin writes
| Key | Store | Written by | Notes |
|---|---|---|---|
_elementor_edit_mode | post meta | Template_Repository | always builder |
_elementor_data | post meta | Template_Repository | wp_slash(wp_json_encode(...)) — slashing is required or WP strips the JSON escapes |
_elementor_template_type | post meta | Template_Repository | library types only |
_elementor_conditions | post meta | Template_Repository | library types only, sanitized string list |
_elementor_page_settings | post meta | Template_Repository, Kit_Applier | per-post and on the kit document |
_wp_page_template | post meta | Template_Repository | pages only; deleted for default values |
elementor_font_files | post meta | Fonts_Applier | per-variation file map |
elementor_font_face | post meta | Fonts_Applier | rendered @font-face CSS |
_cb_managed | post + term meta | Fonts_Applier, Menu_Applier | ownership marker, value '1' |
show_on_front / page_on_front | option | /front-page | the pair that makes a page the site root |
page_for_posts | option | /front-page | cleared only when the promoted page is also the posts page — WordPress refuses that pairing |
elementor_active_kit | option | — | read only, fallback kit resolution |
page_for_posts | option | — | also read, to detect the collision above |
Constants defined: CB_VERSION, CB_PLUGIN_FILE, CB_PLUGIN_DIR, CB_REST_NAMESPACE.
Uninstall (uninstall.php) deliberately deletes nothing, for two reasons.
Content is not the plugin's to delete — removing the bridge that delivered a site must not take
the site with it. And the _cb_managed markers are not safe to delete either: they are what tells
a later re-sync "this menu is mine to replace" rather than "a human built this", so tidying them
away would make every synced menu trip the cb_menu_name_collision 409 forever. The leftover is
the continuity mechanism. PluginBootstrapTest asserts the file stays inert.
3. Usage Index
3.1 Install
# Composer (production / Bedrock) — public on Packagist
composer require hipdevteam/conversion-bridge:^1.11
wp plugin activate conversion-bridge
# Manual
cp -r conversion-bridge/ wp-content/plugins/
wp plugin activate conversion-bridge
Also published to the hipdevteam GitLab Composer registry on every tag — see
INSTALL.md for that and for installing straight from Git.
Full matrix, private-repo auth, and troubleshooting: INSTALL.md.
3.2 Authenticate
Standard WordPress Application Passwords. Create one for an administrator (Users → Profile → Application Passwords) and send it as HTTP Basic auth:
export WP=https://example.com/wp-json/conversion-bridge/v1
export AUTH='admin_user:xxxx xxxx xxxx xxxx xxxx xxxx'
/upsert needs publish_pages (Editor and above); every other route needs manage_options. Since the
client also uploads media and writes kit globals, use an administrator account — anything
less will fail partway through a sync.
3.3 Recommended sync sequence
Order matters: menus must exist before pages reference them, and templates before the front page is promoted.
1. GET /ping → probe capabilities, decide Pro-dependent steps
2. POST /wp/v2/media → upload SVG + font assets (unlocked by this plugin)
3. POST /kit-apply → globals + custom_fonts (site-wide look)
4. POST /menu-apply → returns menu_id → rewrite nav-menu widget settings.menu
5. POST /upsert → header/footer/templates first, then pages & posts
6. POST /front-page → promote the home page
3.4 GET /ping
curl -u "$AUTH" "$WP/ping"
{
"ok": true,
"plugin": "conversion-bridge",
"plugin_version": "1.11.0",
"php_version": "8.2.20",
"wp_version": "6.7.1",
"elementor": "3.30.0",
"elementor_pro": "3.30.0",
"capabilities": { "kit_apply": true, "fonts_apply": true, "menu_apply": true }
}
elementor_pro: null + fonts_apply: false means Pro is inactive — skip Theme Builder types and
custom_fonts.
3.5 POST /upsert
| Field | Type | Req | Notes |
|---|---|---|---|
type | string | ✅ | one of §1.2 |
name | string | ✅ | post title |
elementor_data | array | ✅ | top-level Elementor JSON array, not a string |
slug | string | — | the upsert key. Omit it and every call creates a new post |
page_settings | object | — | → _elementor_page_settings |
wp_page_template | string | — | page type only; elementor_canvas, elementor_header_footer, … |
conditions | string[] | — | library types only, e.g. include/general |
curl -u "$AUTH" -X POST "$WP/upsert" -H 'Content-Type: application/json' -d '{
"type": "header",
"name": "Main Header",
"slug": "main-header",
"conditions": ["include/general"],
"elementor_data": [
{ "id": "a1b2c3", "elType": "container", "settings": {}, "elements": [] }
]
}'
{
"id": 412,
"type": "header",
"post_type": "elementor_library",
"created": true,
"edit_url": "https://example.com/wp-admin/post.php?post=412&action=elementor",
"preview_url": "https://example.com/?p=412"
}
Gotcha:
elementor_datamust be the decoded array. The plugin JSON-encodes and slashes it for you — pre-encoding it to a string yields a template Elementor renders as empty.
3.6 POST /kit-apply
Send page_settings, custom_fonts, or both. Sending neither → 400 cb_empty_request.
curl -u "$AUTH" -X POST "$WP/kit-apply" -H 'Content-Type: application/json' -d '{
"page_settings": {
"system_colors": [
{ "_id": "primary", "title": "Primary", "color": "#1A73E8" },
{ "_id": "secondary", "title": "Secondary", "color": "#5F6368" }
],
"custom_colors": [{ "_id": "brand01", "title": "Brand", "color": "#FF4081" }],
"body_typography_font_family": "Inter",
"container_width": { "unit": "px", "size": 1140 },
"custom_css": ".site { --radius: 8px; }"
},
"custom_fonts": [{
"post_title": "Inter",
"font_files": [{ "font_weight": "400", "font_style": "normal",
"woff2": "https://example.com/wp-content/uploads/inter-400.woff2" }],
"font_face": "@font-face{font-family:\"Inter\";src:url(...) format(\"woff2\");}"
}]
}'
{
"ok": true,
"kit_id": 7,
"applied_keys": ["system_colors", "custom_colors", "body_typography_font_family",
"container_width", "custom_css"],
"palette_sizes": { "system_colors": 2, "custom_colors": 1,
"system_typography": 0, "custom_typography": 0 },
"fonts": { "created": 1, "updated": 0, "skipped": 0,
"imported_fonts": [{ "id": 501, "title": "Inter" }],
"skipped_reasons": [] }
}
Check applied_keys — keys outside the 78-key allow-list are dropped silently, so a typo'd
key looks like success until you notice it missing from the echo.
3.7 POST /menu-apply
curl -u "$AUTH" -X POST "$WP/menu-apply" -H 'Content-Type: application/json' -d '{
"name": "Primary Menu",
"items": [
{ "title": "Home", "url": "https://example.com/" },
{ "title": "Services", "url": "https://example.com/services/" },
{ "title": "SEO", "url": "https://example.com/services/seo/", "parent": 1 }
]
}'
{ "ok": true, "menu_id": 12, "menu_name": "Primary Menu", "created": true,
"item_count": 3, "imported_items": [{ "id": 601, "title": "Home" }] }
parentis a 0-based index into thisitemsarray, not a menu-item ID. Parents must appear before their children.- Items missing
titleorurlare skipped silently — compareitem_countagainst what you sent. - Re-running replaces all items; a same-named unmanaged menu returns 409.
- Feed the returned
menu_idback into each nav-menu widget'ssettings.menubefore uploading pages.
3.8 POST /front-page
curl -u "$AUTH" -X POST "$WP/front-page" -H 'Content-Type: application/json' \
-d '{"page_id": 128}'
{ "success": true, "show_on_front": "page", "page_on_front": 128 }
Must be an existing post of type page (404 otherwise). Drafts are auto-published.
3.9 Uploading SVG / fonts
No custom endpoint — the plugin just unblocks core's media route:
curl -u "$AUTH" -X POST https://example.com/wp-json/wp/v2/media \
-H 'Content-Disposition: attachment; filename="inter-400.woff2"' \
-H 'Content-Type: font/woff2' --data-binary @inter-400.woff2
Use the returned source_url in your custom_fonts.font_files payload.
4. Extensibility Index
| Hook | Kind | Signature | Fires |
|---|---|---|---|
cb_type_map | filter | (array<string, array{post_type: string, template_type: string\|null}> $map) | The single source of truth for both the schema enum and upsert() validation — add types here |
cb_supported_types | filter | (list<string> $types) | The /upsert schema enum only. Restrict-only: a type listed here but absent from cb_type_map still 400s |
cb_upsert_payload | filter | (array $payload) | Start of Template_Repository::upsert(), before validation |
cb_before_upsert | action | (array $payload) | In the controller, before any write |
cb_after_upsert | action | (int $post_id, array $payload, array $result) | After a successful upsert |
cb_integration_error | action | (string $context, \Throwable $error) | An optional Elementor integration threw and was swallowed. Attach a logger — otherwise an API that moves is entirely silent |
cb_max_elementor_bytes | filter | (int $bytes) | Ceiling on encoded elementor_data, default 8 MiB. Return 0 to disable |
cb_max_menu_items | filter | (int $max) | Ceiling on /menu-apply items, default 500. Return 0 to disable |
// Reject Theme Builder types on a free-Elementor site.
add_filter('cb_supported_types', fn(array $t) => defined('ELEMENTOR_PRO_VERSION')
? $t
: array_values(array_intersect($t, ['page', 'post', 'section'])));
// Add a type end to end — schema enum *and* validation.
add_filter('cb_type_map', function (array $map): array {
$map['banner'] = ['post_type' => 'elementor_library', 'template_type' => 'banner'];
return $map;
});
// Surface Elementor integration failures that would otherwise be swallowed.
add_action('cb_integration_error', function (string $context, Throwable $e): void {
error_log(sprintf('[conversion-bridge] %s: %s', $context, $e->getMessage()));
}, 10, 2);
// Audit every write.
add_action('cb_after_upsert', function (int $id, array $payload, array $result): void {
error_log(sprintf('[ecb] %s #%d %s', $payload['type'], $id,
$result['created'] ? 'created' : 'updated'));
}, 10, 3);
5. Error Index
All errors are standard WP_Error responses: {"code": ..., "message": ..., "data": {"status": ...}}.
| Code | HTTP | Raised by | Meaning |
|---|---|---|---|
cb_invalid_type | 400 | /upsert | type not in TYPE_MAP |
cb_missing_name | 400 | /upsert, /menu-apply | name empty after trim |
cb_invalid_elementor_data | 400 | /upsert | elementor_data isn't an array |
cb_unencodable_elementor_data | 422 | /upsert | elementor_data can't be JSON-encoded (depth limit, bad UTF-8). Nothing is written — an earlier version stored an empty template and returned 200 |
cb_elementor_data_too_large | 413 | /upsert | Encoded elementor_data exceeds cb_max_elementor_bytes (8 MiB default). Checked before any write |
cb_too_many_menu_items | 413 | /menu-apply | More items than cb_max_menu_items allows (500 default). The menu is not created or purged |
cb_empty_request | 400 | /kit-apply | neither page_settings nor custom_fonts |
cb_empty_payload | 400 | Kit_Applier | no key survived the allow-list |
cb_menu_no_name | 400 | Menu_Applier | empty menu name |
cb_invalid_page_id | 400 | /front-page | page_id ≤ 0 |
cb_page_not_found | 404 | /front-page | no post, or not post type page |
cb_menu_name_collision | 409 | Menu_Applier | unmanaged menu of the same name exists |
cb_elementor_missing | 412 | Kit_Applier | Elementor not active |
cb_elementor_pro_missing | 412 | Fonts_Applier | elementor_font CPT unregistered |
cb_no_active_kit | 500 | Kit_Applier | kit unresolvable via manager and option |
cb_menu_api_missing | 500 | Menu_Applier | core nav-menu APIs absent (shouldn't happen) |
cb_invalid_font_record | — | Fonts_Applier | per-record; increments skipped, never aborts the batch |
rest_forbidden | 401/403 | WP core | capability check failed |
6. Sharp edges
Things worth knowing before you change or deploy this.
/upsertruns onpublish_pages(rest-controller.php:111) — Editor and above. The handler publishes directly and writes arbitrary Elementor data, which Elementor's HTML widget renders unescaped, so anedit_posts(Contributor) gate would have granted more than the role does. Removing a colliding page is gated separately, ondelete_postfor that specific post.- SVGs are stored verbatim, not sanitized. Deliberate — uploads are gated on
unfiltered_html, so an SVG can carry nothing its uploader could not already have put in a post. If you want to drop that assumption,Uploads::fix_filetype()is the single hook point (dropenshrined/svg-sanitizein there). sanitize_settings()strips newlines.Template_Repositoryrunssanitize_text_fieldover every string inpage_settings, which flattens multi-line values likecustom_css.Kit_Applierpasses values through verbatim (bar the</stylestrip) — the two paths still disagree on the same key.- Cross-type dedup trashes, it doesn't delete. Scoped to
page/postof a different type than the one being written, and skipped entirely unless the caller hasdelete_poston the target. A hand-made page sharing a slug with an incoming post is recoverable from the trash. find_font_by_title()is case-sensitive and publish-scoped."Inter"and"inter"become two CPT posts; a trashed font is never revived, a new one is created alongside it.- There is no autoloader. Loading is explicit
require_oncein the bootstrap; adding a class means adding arequire_oncetoo. Deliberate — a dumped classmap could autoload a class outside a WordPress request, where each file'sif (!defined('ABSPATH')) exit;would kill the process. /kit-applywrites the merged array without re-sanitizing existing values. Intentional — Elementor sanitizes at render — but it means the allow-list is the only filter on inbound kit data.
7. Version map
Full detail per release: CHANGELOG.md.
| Version | Added |
|---|---|
| 1.11.0 | Security + correctness pass. /upsert raised to publish_pages, /ping to manage_options; uploads gated on unfiltered_html; cross-type dedup trashes instead of force-deleting; unencodable elementor_data returns 422 instead of silently blanking the template; validate_callback on every sanitized arg; </style stripped from inbound CSS (Css); request ceilings (413); cb_type_map and cb_integration_error hooks; skipped_reasons on the fonts result |
| 1.10.0 | SVG + web-font uploads (Uploads) |
| 1.9.0 | /front-page |
| 1.6.0 | /menu-apply (Menu_Applier) |
| 1.5.0 | Custom Fonts in /kit-apply (Fonts_Applier), composer metadata |
| 1.4.0 | /kit-apply (Kit_Applier) |
| 1.3.0 | wp_page_template on /upsert |
| 1.0.0 | /upsert, /ping, hooks |
Related
- INSTALL.md — install paths, Pro/free matrix, troubleshooting
- CHANGELOG.md — what changed in each release
- tests/README.md — how the suite is built and run