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.

Maintainers

Package info

gitlab.com/hipdevteam/conversion-bridge

Issues

Type:wordpress-plugin

pkg:composer/hipdevteam/conversion-bridge

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

v1.11.0 2026-08-20 20:04 UTC

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.

Version1.11.0
RequiresWordPress 6.0+ · PHP 8.1+
OptionalElementor 3.x (kit globals) · Elementor Pro (Theme Builder, Custom Fonts)
REST namespaceconversion-bridge/v1
LicenseGPL-2.0-or-later
FootprintNo 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

EndpointMethodCapabilityHandler → ClassSince
/pingGETmanage_optionsinline1.0.0
/upsertPOSTpublish_pagesTemplate_Repository1.0.0
/kit-applyPOSTmanage_optionsKit_Applier + Fonts_Applier1.4.0
/menu-applyPOSTmanage_optionsMenu_Applier1.6.0
/front-pagePOSTmanage_optionsinline1.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:

typepost_type_elementor_template_typeNeeds Pro
pagepageNo
postpostNo
headerelementor_libraryheaderYes
footerelementor_libraryfooterYes
sectionelementor_librarysectionNo
megamenuelementor_librarymega-menuYes
singleelementor_librarysingleYes
archiveelementor_libraryarchiveYes
popupelementor_librarypopupYes
error_404elementor_libraryerror_404Yes

The list is filterable — see §4 Extensibility.

1.3 Behavioral features

FeatureWhereWhat it does
Slug-keyed idempotencyfind_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 dedupupsert()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 regenclear_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 resetwrite_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 ceilingsupsert() · 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-listALLOWED_KEYS78 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 mergeapply()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_managedPost 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 guardapply()A same-named menu that is not marked managed returns 409 rather than being overwritten. Hand-built menus are safe.
Menu hierarchyapply()Each item may carry parent: an index into the same items array. Parents must precede children. Yields real dropdown sub-menus.
Fonts degrade inlinehandle_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-publishhandle_front_page()A draft page is published before being promoted, so the site root never resolves to nothing.
SVG + web-font uploadsUploadsUnlocks 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:

FlagDetected via
kit_applyalways true (plugin is active)
fonts_applypost_type_exists('elementor_font') → Elementor Pro present
menu_applywp_create_nav_menu + wp_update_nav_menu_item exist

2. Dependency Index

2.1 Hard requirements

DependencyVersionEnforced where
PHP≥ 8.1Runtime guard in conversion-bridge.php:34:41 — prints an admin notice and returns instead of fatal-ing
WordPress≥ 6.0Header only
composer/installers^1.0 \|\| ^2.0composer.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

DependencyNeeded forAbsence behavior
\Elementor\Plugin/kit-apply, CSS cache clear/kit-apply412 cb_elementor_missing. /upsert still works — it only writes meta.
\Elementor\Plugin::$instance->kits_managerResolving the active kitFalls back to the elementor_active_kit option (stable since Elementor 3.0)
\Elementor\Plugin::$instance->files_managerClearing compiled CSSWrapped in try/catch — cache clearing is an optimization, not correctness
elementor_font CPT (Pro)Custom Fonts412 cb_elementor_pro_missing, or inline fonts.error when bundled with a kit apply
\ElementorPro\...\ThemeBuilder\ModuleConditions cache regentry/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

KeyStoreWritten byNotes
_elementor_edit_modepost metaTemplate_Repositoryalways builder
_elementor_datapost metaTemplate_Repositorywp_slash(wp_json_encode(...)) — slashing is required or WP strips the JSON escapes
_elementor_template_typepost metaTemplate_Repositorylibrary types only
_elementor_conditionspost metaTemplate_Repositorylibrary types only, sanitized string list
_elementor_page_settingspost metaTemplate_Repository, Kit_Applierper-post and on the kit document
_wp_page_templatepost metaTemplate_Repositorypages only; deleted for default values
elementor_font_filespost metaFonts_Applierper-variation file map
elementor_font_facepost metaFonts_Applierrendered @font-face CSS
_cb_managedpost + term metaFonts_Applier, Menu_Applierownership marker, value '1'
show_on_front / page_on_frontoption/front-pagethe pair that makes a page the site root
page_for_postsoption/front-pagecleared only when the promoted page is also the posts page — WordPress refuses that pairing
elementor_active_kitoptionread only, fallback kit resolution
page_for_postsoptionalso 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

FieldTypeReqNotes
typestringone of §1.2
namestringpost title
elementor_dataarraytop-level Elementor JSON array, not a string
slugstringthe upsert key. Omit it and every call creates a new post
page_settingsobject_elementor_page_settings
wp_page_templatestringpage type only; elementor_canvas, elementor_header_footer, …
conditionsstring[]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_data must 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" }] }
  • parent is a 0-based index into this items array, not a menu-item ID. Parents must appear before their children.
  • Items missing title or url are skipped silently — compare item_count against what you sent.
  • Re-running replaces all items; a same-named unmanaged menu returns 409.
  • Feed the returned menu_id back into each nav-menu widget's settings.menu before 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

HookKindSignatureFires
cb_type_mapfilter(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_typesfilter(list<string> $types)The /upsert schema enum only. Restrict-only: a type listed here but absent from cb_type_map still 400s
cb_upsert_payloadfilter(array $payload)Start of Template_Repository::upsert(), before validation
cb_before_upsertaction(array $payload)In the controller, before any write
cb_after_upsertaction(int $post_id, array $payload, array $result)After a successful upsert
cb_integration_erroraction(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_bytesfilter(int $bytes)Ceiling on encoded elementor_data, default 8 MiB. Return 0 to disable
cb_max_menu_itemsfilter(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": ...}}.

CodeHTTPRaised byMeaning
cb_invalid_type400/upserttype not in TYPE_MAP
cb_missing_name400/upsert, /menu-applyname empty after trim
cb_invalid_elementor_data400/upsertelementor_data isn't an array
cb_unencodable_elementor_data422/upsertelementor_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_large413/upsertEncoded elementor_data exceeds cb_max_elementor_bytes (8 MiB default). Checked before any write
cb_too_many_menu_items413/menu-applyMore items than cb_max_menu_items allows (500 default). The menu is not created or purged
cb_empty_request400/kit-applyneither page_settings nor custom_fonts
cb_empty_payload400Kit_Applierno key survived the allow-list
cb_menu_no_name400Menu_Applierempty menu name
cb_invalid_page_id400/front-pagepage_id ≤ 0
cb_page_not_found404/front-pageno post, or not post type page
cb_menu_name_collision409Menu_Applierunmanaged menu of the same name exists
cb_elementor_missing412Kit_ApplierElementor not active
cb_elementor_pro_missing412Fonts_Applierelementor_font CPT unregistered
cb_no_active_kit500Kit_Applierkit unresolvable via manager and option
cb_menu_api_missing500Menu_Appliercore nav-menu APIs absent (shouldn't happen)
cb_invalid_font_recordFonts_Applierper-record; increments skipped, never aborts the batch
rest_forbidden401/403WP corecapability check failed

6. Sharp edges

Things worth knowing before you change or deploy this.

  1. /upsert runs on publish_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 an edit_posts (Contributor) gate would have granted more than the role does. Removing a colliding page is gated separately, on delete_post for that specific post.
  2. 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 (drop enshrined/svg-sanitize in there).
  3. sanitize_settings() strips newlines. Template_Repository runs sanitize_text_field over every string in page_settings, which flattens multi-line values like custom_css. Kit_Applier passes values through verbatim (bar the </style strip) — the two paths still disagree on the same key.
  4. Cross-type dedup trashes, it doesn't delete. Scoped to page/post of a different type than the one being written, and skipped entirely unless the caller has delete_post on the target. A hand-made page sharing a slug with an incoming post is recoverable from the trash.
  5. 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.
  6. There is no autoloader. Loading is explicit require_once in the bootstrap; adding a class means adding a require_once too. Deliberate — a dumped classmap could autoload a class outside a WordPress request, where each file's if (!defined('ABSPATH')) exit; would kill the process.
  7. /kit-apply writes 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.

VersionAdded
1.11.0Security + 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.0SVG + web-font uploads (Uploads)
1.9.0/front-page
1.6.0/menu-apply (Menu_Applier)
1.5.0Custom Fonts in /kit-apply (Fonts_Applier), composer metadata
1.4.0/kit-apply (Kit_Applier)
1.3.0wp_page_template on /upsert
1.0.0/upsert, /ping, hooks

Related