files_manager)) {
\Elementor\Plugin::$instance->files_manager->clear_cache();
self::$css_flushed = true;
}
}
/**
* The id of the kit ELEMENTOR WILL ACTUALLY READ, or 0.
*
* ASK ELEMENTOR. Do not re-implement its answer. Two earlier versions of this method tried,
* and each closed one state and left the others:
*
* v1 `get_option('elementor_active_kit')` — a DELETED post passed
* v2 ...plus `get_post($kit_id)` — a PLAIN POST and a TRASHED KIT passed
*
* Elementor's own test is `$kit && $kit instanceof Kit && 'trash' !== post_status`
* (`core/kits/manager.php`). Anything looser writes the palette onto a post Elementor will
* never read, while `globalize` is still REQUESTED — so the engine rewrites the page's
* colours into `__globals__` references that resolve to nothing, and the customer's page
* renders Elementor's DEFAULTS instead of their own colours. Silently: the error_log net
* needs `0 === $colors_added`, and twelve colours were "added".
*
* Measured in the QA WordPress by `PHL/_kit-authority.php`, all five states:
*
* state v2 (get_post) this method
* healthy kit (4) 4 4
* dead id 999777 0 0
* a plain 'post' 9513 0
* a TRASHED kit 9514 0
* restored (4) 4 4
*
* NOT memoised. A function-static cannot be cleared from PHP, so a memo would make this
* method untestable: the suite drives five kit states in one process and every state after
* the first would read the first one's answer. Elementor's documents manager already caches
* the document, so the four asks in a request cost one load.
*/
/**
* Should this request ask the API for Site Styles at all?
*
* TWO conditions, and both are load-bearing. The SETTING is consent: without it nothing about
* the customer's site-wide kit is any of our business. The KIT is capability: with `globalize`
* the ENGINE rewrites the page's colours into `__globals__` references, and a reference with
* no kit behind it does not fall back to the inline colour — it renders Elementor's DEFAULT.
* Asking without a kit is therefore strictly worse than not asking.
*
* Extracted from `ajax_convert()` because review pointed out the rule had no test: the only
* way to reach that line was to drive the whole AJAX handler, which terminates in
* `wp_send_json`. A rule nothing can exercise is a rule waiting to be edited by accident.
* `test-site-styles.php` now drives this across every kit state, and asserts that
* `ajax_convert()` still calls it rather than rebuilding the condition inline.
*/
private static function should_globalize() {
return get_option('htel_globalize', false) && (bool) self::active_kit_id();
}
private static function active_kit_id() {
// F3 (review cycle 4): when the kit OPTION itself is empty, asking Elementor's
// kits_manager instantiates document machinery that warns ('post_status on null') on
// every conversion of a broken-kit site - and on display_errors hosts those warnings
// prefix the AJAX JSON exactly like cycle 3's blocker. Nothing to write to anyway.
if (!get_option('elementor_active_kit')) {
return 0;
}
if (!class_exists('\Elementor\Plugin')) {
return 0; // Elementor not loaded — no kit to write to, skip silently.
}
$plugin = \Elementor\Plugin::$instance;
if (!$plugin || empty($plugin->kits_manager)) {
return 0;
}
$kit = $plugin->kits_manager->get_active_kit();
if (!$kit) {
return 0;
}
$post = $kit->get_main_post();
if (!$post || empty($post->ID)) {
return 0; // Elementor hands back an EMPTY kit instance for every broken state.
}
return (int) $post->ID;
}
/** Kit entries one conversion may add, mirroring the engine's own caps. See inject_*. */
const MAX_KIT_COLORS = 12;
const MAX_KIT_FONTS = 4;
/**
* AJAX handler for conversion.
*/
public static function ajax_convert() {
check_ajax_referer('htel_convert_nonce', 'nonce');
$ready = self::conversion_preflight();
if (is_wp_error($ready)) {
self::send_failure($ready);
}
// Get HTML input
$html = '';
$title = isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : 'Imported Page';
// Site-part intent ("I'm converting a header or footer" checkbox, copy approved
// 2026-08-14). Strictly 'header'|'footer'; anything else is dropped so the API
// emits the ordinary page envelope.
$intent = isset($_POST['intent']) ? sanitize_text_field(wp_unslash($_POST['intent'])) : '';
if (!in_array($intent, array('header', 'footer'), true)) { $intent = ''; }
$imported_images = array(); // Track imported image count
if (!empty($_FILES['zip_file']) && isset($_FILES['zip_file']['tmp_name'])) {
// ─── ZIP upload: extract HTML + import images ───
// Build a sanitized copy of the $_FILES entry before passing to
// the handler. Per WP.org review, every $_FILES field read is
// user input and must be sanitized at the boundary.
$zip_file_sanitized = array(
'name' => isset($_FILES['zip_file']['name']) ? sanitize_file_name(wp_unslash($_FILES['zip_file']['name'])) : '',
'tmp_name' => isset($_FILES['zip_file']['tmp_name']) ? sanitize_text_field(wp_unslash($_FILES['zip_file']['tmp_name'])) : '',
'error' => isset($_FILES['zip_file']['error']) ? (int) $_FILES['zip_file']['error'] : UPLOAD_ERR_NO_FILE,
'size' => isset($_FILES['zip_file']['size']) ? (int) $_FILES['zip_file']['size'] : 0,
);
$result = self::handle_zip_upload($zip_file_sanitized, $title);
if (is_wp_error($result)) {
wp_send_json_error(array('message' => $result->get_error_message()));
}
$html = $result['html'];
$title = $result['title'];
$imported_images = $result['imported_images'];
} elseif (!empty($_POST['html_b64'])) {
// Base64-encoded HTML body. The JS encodes the payload before POSTing
// to bypass server-level WAFs (mod_security, Imunify360, BitNinja) on
// shared cPanel hosts that pattern-match request bodies for HTML/script
// signatures and 403 the request before it reaches WordPress. The
// Apache 403 footer is the giveaway — see batch10/SA-customer issue.
//
// Encoding is opaque to WAFs (just base64 chars). Decoding here gets
// us back to the original HTML for the rest of the conversion pipeline,
// identical to the legacy `html` field path below.
//
// wp_unslash because POST data goes through WP's magic-quotes-style
// slash addition; base64_decode is permissive and tolerates the
// expected trailing '=' padding (which doesn't get slash-escaped).
$b64 = wp_unslash($_POST['html_b64']);
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$decoded = base64_decode($b64, true);
if ($decoded === false) {
wp_send_json_error(array('message' => esc_html__('Invalid encoded HTML payload', 'aitoel-html-importer')));
}
$html = $decoded;
} elseif (!empty($_POST['html'])) {
// Legacy plain-text HTML path (kept for backwards compatibility with
// any third-party integrations that POST directly to admin-ajax).
// Note: HTML content is intentionally NOT sanitized with wp_kses() because
// the entire purpose of this plugin is to convert arbitrary HTML to Elementor.
// Sanitizing would strip the HTML structures we need to convert.
// The HTML is sent to our external API for conversion and never rendered
// directly on the site — it is transformed into Elementor JSON data.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$html = wp_unslash($_POST['html']);
} elseif (!empty($_FILES['html_file']) && isset($_FILES['html_file']['tmp_name'])) {
// Validate file upload structure BEFORE reading any of its fields.
// $_FILES entries are user input and must be sanitized per WP.org
// security review. We:
// - sanitize_file_name() the client-supplied name (never trust it)
// - sanitize_text_field() the tmp_name path before filesystem ops
// - reject anything that isn't the expected structure
$upload_error = isset($_FILES['html_file']['error']) ? (int) $_FILES['html_file']['error'] : UPLOAD_ERR_NO_FILE;
$upload_name = isset($_FILES['html_file']['name']) ? sanitize_file_name(wp_unslash($_FILES['html_file']['name'])) : '';
$upload_tmp = isset($_FILES['html_file']['tmp_name']) ? sanitize_text_field(wp_unslash($_FILES['html_file']['tmp_name'])) : '';
if ($upload_error !== UPLOAD_ERR_OK) {
wp_send_json_error(array('message' => esc_html__('File upload failed', 'aitoel-html-importer')));
}
if (empty($upload_name) || empty($upload_tmp) || !is_uploaded_file($upload_tmp)) {
wp_send_json_error(array('message' => esc_html__('Invalid upload', 'aitoel-html-importer')));
}
$ext = strtolower(pathinfo($upload_name, PATHINFO_EXTENSION));
if ($ext !== 'html' && $ext !== 'htm') {
wp_send_json_error(array('message' => esc_html__('Only .html and .htm files are accepted', 'aitoel-html-importer')));
}
// Use WP_Filesystem if available; fall back to file_get_contents.
// The file lives on the server's tmp dir, validated above.
$html = file_get_contents($upload_tmp);
if ($html === false) {
wp_send_json_error(array('message' => esc_html__('Could not read uploaded file', 'aitoel-html-importer')));
}
if (empty($title) || $title === 'Imported Page') {
// Derive a safe title from the sanitized filename. pathinfo() on
// the already-sanitized $upload_name is safe, and we run the
// result through sanitize_text_field() before sending it to the
// API where it becomes the post_title.
$derived_title = pathinfo($upload_name, PATHINFO_FILENAME);
$title = sanitize_text_field($derived_title);
if (empty($title)) {
$title = 'Imported Page';
}
}
}
$result = self::run_conversion($html, $title, $intent, count($imported_images));
if (is_wp_error($result)) {
self::send_failure($result);
}
wp_send_json_success($result);
}
/**
* The checks every conversion starts with, whoever asks for it: the Convert button or the
* `aitoel/convert-html` ability.
*
* @return true|WP_Error
*/
private static function conversion_preflight() {
if (!current_user_can('edit_posts')) {
return new WP_Error('htel_forbidden',
esc_html__('You do not have permission to create templates', 'aitoel-html-importer'), array('status' => 403));
}
// A converted template carries its page's stylesheet and scripts, which WordPress lets only a user
// with the right to save unfiltered HTML store. One without it is refused here, before anything is
// read, sent or counted. (In a REST request Elementor would filter that user's template instead.)
if (!current_user_can('unfiltered_html')) {
return new WP_Error('htel_unfiltered_html_required',
esc_html__('Your WordPress user is not allowed to save unfiltered HTML on this site, and a converted template needs that for its styles. Nothing was converted and no conversion was used. Ask a user who may save unfiltered HTML to run it: on a standard site, an administrator or an editor.', 'aitoel-html-importer'),
array('status' => 403));
}
if (!HTEL_Elementor_Check::is_active()) {
return new WP_Error('htel_elementor_inactive',
esc_html__('Elementor must be installed and activated', 'aitoel-html-importer'), array('status' => 503));
}
return true;
}
/**
* Answers the Convert button with a failed conversion: the error's message, and the fields the
* page's script reads beside it (`reason`, `usage`, `not_charged`, `error_code`, `raw_error`).
* The HTTP status an error carries is for the REST route and is not part of the button's answer.
*/
private static function send_failure($error) {
// Every error a conversion returns is built in this class with an array as its data.
$fields = $error->get_error_data();
unset($fields['status']);
wp_send_json_error(array_merge(array('message' => $error->get_error_message()), $fields));
}
/**
* Runs a conversion for the `aitoel/convert-html` ability (HTEL_Abilities): the checks, the request,
* the save and the confirm of the Convert button, on HTML an AI client hands over instead of the form.
*
* @param mixed $input `html`, and optionally `title` and `intent`, as the ability's schema describes them.
* @return array|WP_Error The data the button answers on a success, or the failure as an error.
*/
public static function ability_convert($input) {
$ready = self::conversion_preflight();
if (is_wp_error($ready)) {
return $ready;
}
// The Abilities API checks the input against the schema first; a direct caller may hand over anything.
$input = (array) $input;
$html = $input['html'] ?? '';
$title = sanitize_text_field($input['title'] ?? '');
$intent = $input['intent'] ?? '';
return self::run_conversion(
is_string($html) ? $html : '',
$title !== '' ? $title : 'Imported Page',
in_array($intent, array('header', 'footer'), true) ? $intent : '',
0
);
}
/**
* One conversion: sends the HTML to the conversion service, saves the template it returns and, only
* once the template is on the site, confirms the conversion so that it counts.
*
* @param string $html The page's HTML (or its public URL).
* @param string $title The template's title, already plain text.
* @param string $intent 'header', 'footer' or ''.
* @param int $image_count Images an uploaded archive brought with it.
* @return array|WP_Error The success data, or the failure with the button's extra fields and an
* HTTP status as its error data.
*/
private static function run_conversion($html, $title, $intent, $image_count) {
// Check license — allow free tier (no license) or paid
$has_license = HTEL_License::is_valid_for_conversion();
$license_key = $has_license ? HTEL_License::get_key() : '';
if (empty($html)) {
return new WP_Error('htel_no_html', esc_html__('No HTML content provided', 'aitoel-html-importer'), array('status' => 400));
}
// Size check (5MB for HTML content after extraction)
if (strlen($html) > 5 * 1024 * 1024) {
return new WP_Error('htel_too_large', esc_html__('HTML exceeds 5MB limit', 'aitoel-html-importer'), array('status' => 413));
}
// Send to API
$api_url = HTEL_License::get_api_url() . '/api/convert';
$domain = HTEL_License::get_domain();
// Legacy opt-IN telemetry (still honoured for users who previously enabled it)
$telemetry = get_option('htel_telemetry_opt_in', false);
// Conversion Quality Sampling (ToS §9.4) — enabled by default, user can opt OUT.
// We pass the opt-out flag to the API; if true, the API skips sampling.
$sampling_opt_out = get_option('htel_sampling_opt_out', false);
// Site Styles (Global Colors + Global Fonts). Opt-in and off by default — it writes
// into the ACTIVE KIT, which is site-wide. Without the flag the API returns neither a
// palette nor a font list and both injection paths below stay inert, exactly as they
// did between 2026-08-07 and 2026-08-27.
// AND NOT WITHOUT A KIT TO PUT IT IN. With `globalize`, the ENGINE rewrites the page's
// colours into `__globals__` references — and a dangling reference renders Elementor's
// DEFAULT colour, not the author's. So if there is no active kit the page would come back
// recoloured with nothing to fix it. Asking only when a kit exists means the engine never
// makes the references in the first place.
$globalize = self::should_globalize();
$request_body = array_filter(array(
'html' => $html,
'title' => $title,
'license_key' => $license_key ?: null,
'domain' => $domain,
'telemetry_opt_in' => $telemetry ? true : null,
'sampling_opt_out' => $sampling_opt_out ? true : null,
'globalize' => $globalize ? true : null,
// #175 capability declaration: THIS build injects FULL typography presets
// (size/weight/responsive, not family-only) into the kit atomically with the
// template - so the engine may emit __globals__ typography refs. Only sent
// alongside globalize: without a kit to inject into, a ref would dangle and
// render the kit DEFAULT, exactly like the colours.
'caps' => $globalize ? array('typography_presets' => true) : null,
// The site's Elementor version — lets the engine pick version-correct widgets
// (on 4.x the classic accordion can ship legacy-off and render EMPTY, so the
// engine emits native nested-accordion instead). Absent on old plugins and raw
// JSON downloads → the engine keeps classic output, so this can never break.
'elementor_version' => defined('ELEMENTOR_VERSION') ? ELEMENTOR_VERSION : null,
// Ask for native SVG icons. Only the plugin may request this: the returned Image
// widgets carry the SVG in a `__hte_svg` payload that import_svg_icons() consumes —
// uploading it when enabled, or rewriting the widget back to inline-SVG HTML when the
// site owner has it off. There is now a THIRD outcome the previous version of this
// comment did not admit: when nothing survives the safety filter the widget is DELETED
// rather than filled with the raw payload, because writing back the one input the
// filter rejected is not a fallback, it is the defence running backwards.
// (A raw JSON download, which cannot consume the payload, never sends this flag.)
'svg_icons' => true,
// Declared site-part intent — the engine emits a header/footer-typed template
// (sticky kept, hamburger kept + shimmed) and the grader skips the fragment
// warning because the customer told us.
'intent' => $intent ?: null,
// Pro present AND the nav-menu widget is real (not the free tier's
// promo stub) → the API maps a declared header's nav to a native
// nav-menu widget + __hte_menu payload consumed below at import.
'elementor_pro' => HTEL_Elementor_Check::is_pro_nav_menu_usable() ? true : null,
// Opt into the pending+confirm flow: the API logs the conversion with
// confirmed_at = NULL and advances NO counter until confirm_conversion()
// reports that the template actually landed on this site. Without this the
// API falls back to counting the moment IT finishes, which burned the
// customer's quota for imports that never reached them.
'wants_confirm' => true,
));
$response = wp_remote_post($api_url, array(
'timeout' => 120, // Allow up to 2 minutes for large pages
'headers' => array('Content-Type' => 'application/json'),
'body' => wp_json_encode($request_body),
));
// Store the HTML in a transient so the "Report a problem" button can retrieve it
set_transient('htel_last_conversion_html', $html, 30 * MINUTE_IN_SECONDS);
if (is_wp_error($response)) {
$err = $response->get_error_message();
$code = $response->get_error_code();
$diag = self::build_connection_error_diagnostic($err, $code, $api_url, strlen(wp_json_encode($request_body)));
return new WP_Error('htel_service_unreachable', $diag, array(
'error_code' => $code,
'raw_error' => $err,
'status' => 502,
));
}
$code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($code !== 200 || empty($body['success'])) {
$error = isset($body['error']) ? $body['error'] : 'Conversion failed (HTTP ' . $code . ')';
// The REST route answers the service's own status; a 200 that is not a success is a bad gateway.
$status = $code >= 400 ? (int) $code : 502;
// Free tier service quota reached — neutral error relay (no
// upgrade CTA per WP.org Guideline 5).
if ($error === 'free_limit_reached') {
// `reason` lets the UI stop calling this a failure and drop the retry button that
// cannot succeed — the same mechanism already used for js_rendered_page below.
// It carries NO quota numbers and NO upgrade payload: the `free_tier` metadata
// removed in the Guideline 5 pass is deliberately NOT reinstated.
return new WP_Error('htel_free_limit_reached',
isset($body['message']) ? $body['message'] : esc_html__('Conversion service quota reached for this site this month.', 'aitoel-html-importer'),
array('reason' => 'free_limit_reached', 'status' => $status));
}
// Paid-tier monthly quota reached — relay the API's structured
// usage so the settings page can show "X/Y used this month"
// without an extra round-trip. No upgrade CTA in plugin UI.
if ($error === 'monthly_limit_reached') {
if (isset($body['usage']) && is_array($body['usage'])) {
update_option('htel_license_usage', $body['usage']);
}
return new WP_Error('htel_monthly_limit_reached',
isset($body['message']) ? $body['message'] : esc_html__('Monthly conversion service quota reached.', 'aitoel-html-importer'),
array(
'usage' => isset($body['usage']) ? $body['usage'] : null,
'reason' => 'monthly_limit_reached',
'status' => $status,
));
}
// JS-rendered page (React/Vue/Next/etc. SPA shell) — the page's content
// is generated at runtime by JavaScript, so there was nothing static to
// import. The API did NOT charge this conversion. Relay the guidance
// message instead of a generic failure so the user knows what to do.
if ($error === 'js_rendered_page') {
return new WP_Error('htel_js_rendered_page',
isset($body['message']) ? $body['message'] : esc_html__('This page is built by JavaScript, so there was no static content to import. Save the fully-rendered HTML from your browser (right-click → Save As → “Webpage, Complete”) and upload that file, or paste the page URL. You were not charged for this conversion.', 'aitoel-html-importer'),
array('reason' => 'js_rendered_page', 'not_charged' => true, 'status' => $status));
}
return new WP_Error('htel_conversion_failed', $error, array('status' => $status));
}
// Persist the live usage counter from the API response so the
// settings page reflects current month's usage without a
// validate round-trip after each conversion.
if (isset($body['usage']) && is_array($body['usage'])) {
update_option('htel_license_usage', $body['usage']);
}
// Save as Elementor template
$template = $body['template'];
$fonts = isset($body['meta']['fonts']) ? $body['meta']['fonts'] : array();
$element_count = isset($body['meta']['element_count']) ? intval($body['meta']['element_count']) : 0;
// Global Colors palette (present only when globalize was requested + supported).
// The template's __globals__ refs dangle until these land in the kit, so inject
// them atomically with the page (inside save_elementor_template).
list($global_colors, $global_fonts, $global_typography) = self::site_styles_from_response($body);
// Global Fonts, the same contract: present only when globalize was requested AND the
// deployed converter returned a list. Family-level presets — Elementor's typography
// globals are all-or-nothing bundles, so sizes are deliberately not mapped.
$template_id = self::save_elementor_template($template, $title, $fonts, $global_colors, $global_fonts, $global_typography);
if (is_wp_error($template_id)) {
return new WP_Error('htel_save_failed',
/* translators: %s: technical error message returned by WordPress */
sprintf(esc_html__('Failed to save template: %s', 'aitoel-html-importer'), $template_id->get_error_message()),
array('status' => 500));
}
$edit_url = admin_url('post.php?post=' . $template_id . '&action=elementor');
$library_url = admin_url('edit.php?post_type=elementor_library');
// Site-part success note (approved copy 2026-08-14): tells the customer where the
// header/footer landed and how to use it, Pro vs non-Pro.
$site_part_note = self::full_success_note(
is_array($template) ? $template : array(),
self::$last_menu_stats
);
$success_data = array(
'message' => esc_html__('Template created successfully!', 'aitoel-html-importer'),
'template_id' => $template_id,
'edit_url' => $edit_url,
'library_url' => $library_url,
'element_count' => $element_count,
'font_count' => count($fonts),
'image_count' => $image_count,
// The converter emits flexbox-container markup. If this site has the
// Container feature OFF the new template renders BLANK on the front end,
// so the UI surfaces a prominent warning (and a one-click enable for admins).
'containers_active' => HTEL_Elementor_Check::are_containers_active(),
'can_enable_containers'=> HTEL_Elementor_Check::current_user_can_enable_containers(),
// Compatibility heads-up from the conversion service: null on a clean page (the plugin
// shows nothing), or { tier, headline, message, issues:[{reason,fix}] } when the page
// has features that may not convert perfectly. The template still imported either way.
'compatibility' => isset($body['meta']['compatibility']) ? $body['meta']['compatibility'] : null,
// The site-part copy when a header/footer landed, PLUS a menu sentence on any conversion
// that wrote or reused a WordPress menu — ordinary pages included..
'site_part_note' => $site_part_note,
// Widget types the template uses that this site's Elementor does NOT register
// (Elementor 4 "legacy widgets" case) — those blocks render EMPTY until enabled.
// [] when everything renders. Also persisted for the admin notice.
'missing_widgets' => HTEL_Elementor_Check::record_template_widget_compat($template['content']),
);
// Per WP.org Guideline 5 (Trialware): the plugin no longer
// forwards free_tier metadata to the JS layer (the metadata was
// previously used to trigger an in-plugin upgrade CTA, which has
// been removed). The external service still enforces quotas;
// when reached they surface via the standard error-message path.
// The template is on the site — only now may the conversion be counted.
// Deliberately after every failure path above: anything that aborts earlier
// leaves the row unconfirmed and the customer's quota untouched.
self::confirm_conversion(
isset($body['conversion_id']) ? $body['conversion_id'] : '',
$domain,
empty($license_key)
);
return $success_data;
}
/**
* Add the converted page's palette to the active Elementor kit as Global Colors.
*
* ADDITIVE ONLY: dedup by `_id`; our ids are `hte`-prefixed so they never collide with
* Elementor's system colors (primary/secondary/text/accent) or a customer's own. An entry
* the kit already holds is left exactly as it is — position, title AND value, ours or
* theirs — and nothing is ever removed or reordered. "Refresh" used to be in this
* sentence and in the code; both are gone, because `_id` is a hash of the colour, so
* refreshing an existing id could never carry new information and could only revert a
* change the CUSTOMER had made. Runs in the same request as the page save so the
* template's `__globals__` references resolve on first render.
*
* @param array $global_colors [{_id,title,color}, ...] from the API.
* @return int Count of newly-added colors (0 if all present / kit missing).
*/
private static function inject_global_colors($global_colors) {
if (empty($global_colors) || !is_array($global_colors)) {
return 0;
}
$kit_id = self::active_kit_id();
if (!$kit_id) {
return 0; // No usable kit (absent, or the option names a post that is gone).
}
$settings = get_post_meta($kit_id, '_elementor_page_settings', true);
if (!is_array($settings)) {
$settings = array();
}
$existing = (isset($settings['custom_colors']) && is_array($settings['custom_colors']))
? $settings['custom_colors'] : array();
// ROWS WITHOUT AN `_id` ARE THE CUSTOMER'S AND MUST SURVIVE. The merge rebuilds the list
// from `$order`, and `$order` was built only from rows that HAVE an id — so an entry
// written by a theme demo kit, a migration or a third-party plugin was silently and
// permanently deleted on the customer's first conversion. Measured: a row titled
// "Client brand red" with no `_id` vanished, while the checkbox promised "Nothing is ever
// removed". Unkeyed rows are now carried through in place.
$by_id = array();
$order = array();
$unkeyed = array();
foreach ($existing as $c) {
// A DUPLICATE ID ALREADY IN THE KIT USED TO DESTROY A ROW. `$order` collected the raw
// id every time it appeared, and the rebuild emitted `$by_id[$id]` once per
// occurrence — so a kit holding two rows under the same id came back as the SECOND
// one, twice, and the first was gone. The row COUNT was unchanged, which is why a
// count assertion could not see it. Measured by review: A/B/C in, B/B/C out.
//
// A repeat is treated as unkeyed: it keeps its own contents and its own position, and
// only the first occurrence owns the id for merging.
if (isset($c['_id']) && is_string($c['_id']) && '' !== $c['_id']
&& !isset($by_id[$c['_id']])) {
$by_id[$c['_id']] = $c; $order[] = $c['_id'];
} else {
$unkeyed[] = $c; $order[] = array('__unkeyed' => count($unkeyed) - 1);
}
}
// A CAP ON OUR SIDE TOO. The engine caps at 12 colours and 4 fonts per page, but the
// engine deploys separately from this plugin AND `htel_api_url` is customer-settable, so
// the bound that protects the customer's kit cannot live only at the other end of an
// HTTP call. Review wrote 200 entries through this function with a hand-made payload.
// COUNTED. Twelve is the cap and Elementor does not paginate this list, so the cap is
// right — but a page carrying twenty colours quietly lost eight of them, and the
// customer had no way to know which or why.
$kitmalformed = 0;
$kitcolorcap = max(0, count($global_colors) - self::MAX_KIT_COLORS);
$global_colors = array_slice($global_colors, 0, self::MAX_KIT_COLORS);
$added = 0;
foreach ($global_colors as $c) {
// ONE LOAD-BEARING CHECK, NOT TWO. This carried an `is_string()` pair as well, which
// fired FIRST for an array id or an array colour — so the empty-string refusal below
// could never run for those cases, and ablating it left the suite green. That is the
// same decorative-guard finding review made about the fonts half, surviving on this
// side because the two halves had drifted apart. `sanitize_text_field()` returns ''
// for an array (measured), so the empty check catches everything the pair did.
if (!isset($c['_id']) || !isset($c['color'])) {
$kitmalformed++;
continue;
}
$id = sanitize_text_field($c['_id']);
$color = sanitize_text_field($c['color']);
// Both checks are load-bearing: `sanitize_text_field()` returns '' for an array, so an
// array id or an array colour arrives here as an empty string. Review found the colour
// half had no such check and wrote `{"color":""}` into the kit.
if ('' === $id || '' === $color) {
$kitmalformed++;
continue;
}
// AN ENTRY THAT ALREADY EXISTS IS NOT OURS TO TOUCH, in any field.
//
// The previous version kept the customer's title and updated the VALUE, on the
// reasoning that the value is what the id identifies. That reasoning is backwards
// here: `_id` IS a hash of the colour, so for a given id our payload can only ever
// carry the SAME colour we first sent. The update branch could therefore never
// deliver new information — its only possible effect was to overwrite a value the
// CUSTOMER had changed, on a Global Color that repaints pages they never converted.
//
// So an existing id is skipped entirely. Additive, never destructive.
if (isset($by_id[$id])) { continue; }
$by_id[$id] = array(
'_id' => $id,
// `is_string` to match the font side. Without it an array title reaches
// `sanitize_text_field()`, which returns '' — so a malformed payload wrote an
// UNNAMED swatch instead of falling back to 'Imported'. No error, no wrong
// colour, just a blank row in the customer's picker.
'title' => isset($c['title']) && is_string($c['title'])
? sanitize_text_field($c['title']) : 'Imported',
'color' => $color,
);
$order[] = $id;
$added++;
}
$merged = array();
foreach ($order as $id) {
$merged[] = is_array($id) ? $unkeyed[$id['__unkeyed']] : $by_id[$id];
}
$settings['custom_colors'] = $merged;
self::report_drops(array('kitcolorcap' => $kitcolorcap, 'kitmalformed' => $kitmalformed));
// SLASHED (#319). update_post_meta() unslashes what it is given, and `$settings` is the
// customer's WHOLE kit as read back: written raw, WordPress stripped its backslashes, so a
// conversion adding the page's colours broke the escapes in their own Site Settings Custom CSS.
update_post_meta($kit_id, '_elementor_page_settings', wp_slash($settings));
// ONLY IF SOMETHING CHANGED. `clear_cache()` runs `delete_post_meta_by_key()` three times
// across the whole site and unlinks the generated CSS directory — measured at 26 cached
// rows to 0 on a re-import that added NOTHING. A customer re-importing a page they already
// converted was paying for their entire site's CSS to be regenerated for no change.
if ($added > 0) { self::flush_elementor_css(); }
return $added;
}
/**
* Merge Global Fonts into the active kit's `custom_typography`.
*
* The exact mirror of inject_global_colors(), and it writes to the control Elementor itself
* calls "Custom Fonts" — verified against the plugin's own source rather than assumed:
* `core/kits/documents/tabs/global-typography.php` declares `custom_typography` as a repeater
* with the same shape as `custom_colors`, while `system_typography` has `add` and `remove`
* disabled because its four entries (primary/secondary/text/accent) are fixed. So the custom
* list is the only place an imported font belongs, and system_typography is left untouched.
*
* The field keys are Elementor's own: TYPOGRAPHY_GROUP_PREFIX is `typography_`, giving
* `typography_typography` and `typography_font_family` — which is exactly what the engine has
* been emitting since 2026-07-17.
*
* ADDITIVE ONLY: an entry already in the kit is left exactly as it is — position, title and
* family — and nothing is ever removed. Only ids the kit has never seen are appended. See the
* reasoning at the skip below: `_id` is a hash of the family, so an existing id can only hold
* the family we already sent, and "updating" it could do nothing except revert a customer's
* change.
*
* @param array $global_fonts [{_id,title,typography_typography,typography_font_family}, ...]
* @return int Count of newly-added fonts (0 if all present / kit missing).
*/
/**
* #175 - merge FULL typography presets into the kit's `custom_typography`.
*
* The mirror of inject_global_fonts() one field-list wider: these entries carry sizes,
* weights, line-heights and their _tablet/_mobile variants, because the engine only emits
* them to a caller that declared `caps.typography_presets` - this build. Every field is
* rebuilt from an allow-list; sized values must be {unit, size} with a numeric size and a
* known unit, or the FIELD is dropped (never the row - a preset missing one odd field
* still styles everything else, while a dropped row dangles every widget that refs it).
*
* ADDITIVE ONLY, same as the palette: existing ids are never touched, so a customer's
* edit to an imported preset survives re-import. Ids are `ht`-prefixed by the engine.
*
* @param array $global_typography [{_id,title,typography_typography,typography_*...}, ...]
* @return int newly-added presets.
*/
/** Ids whose incoming values differed from the kit's existing row - their refs must not stand. */
private static $typography_mismatched_ids = array();
// Icons that reached the page as inline markup instead of an editable Image widget, split by
// cause. report_drops() records these too, but it ends in error_log() and therefore NEVER
// reaches a customer — the same gap the nav-menu fallback had, fixed the same way: a sentence
// in the success note. Reset per conversion so a second import cannot inherit the first count.
private static $svg_held = 0; // native icon import is switched OFF on this site
private static $svg_write = 0; // it is switched ON and the write failed
/**
* Does the kit's row for $id carry the same TYPOGRAPHY VALUES as the incoming preset?
* Title is ignored - a customer renaming 'Heading 2' to 'My H2' must not force a mismatch.
* Field-by-field over the union of typography_* keys; sized values compare unit+size.
*/
private static function typography_values_match($existing, $id, $incoming) {
$stored = null;
foreach ($existing as $row) {
if (isset($row['_id']) && $row['_id'] === $id) { $stored = $row; break; }
}
if (!is_array($stored)) {
return false;
}
$keys = array();
foreach (array_merge(array_keys($stored), array_keys($incoming)) as $k) {
if (0 === strpos($k, 'typography_') && 'typography_typography' !== $k) { $keys[$k] = true; }
}
foreach (array_keys($keys) as $k) {
$a = isset($stored[$k]) ? $stored[$k] : null;
$b = isset($incoming[$k]) ? $incoming[$k] : null;
if (is_array($a) !== is_array($b)) {
return false; // one side sized, one not - mismatch, and never a string-cast warning
}
if (is_array($a) && is_array($b)) {
$ua = isset($a['unit']) ? $a['unit'] : ''; $ub = isset($b['unit']) ? $b['unit'] : '';
$sa = isset($a['size']) ? (float) $a['size'] : null; $sb = isset($b['size']) ? (float) $b['size'] : null;
if ($ua !== $ub || $sa !== $sb) { return false; }
} elseif ((string) $a !== (string) $b) {
return false;
}
}
return true;
}
/**
* Strip the __globals__ typography refs that point at mismatched ids, so this page keeps its
* own inline styling instead of adopting another page's preset. Walks the whole tree.
*/
private static function strip_mismatched_typography_refs(&$elements) {
if (empty(self::$typography_mismatched_ids) || !is_array($elements)) {
return 0;
}
$stripped = 0;
$ids = self::$typography_mismatched_ids;
$walk = function (&$els) use (&$walk, &$stripped, $ids) {
foreach ($els as &$el) {
if (isset($el['settings']['__globals__']['typography_typography'])
&& is_string($el['settings']['__globals__']['typography_typography'])) {
foreach ($ids as $id) {
if (false !== strpos($el['settings']['__globals__']['typography_typography'], 'id=' . $id)) {
unset($el['settings']['__globals__']['typography_typography']);
if (empty($el['settings']['__globals__'])) { unset($el['settings']['__globals__']); }
$stripped++;
break;
}
}
}
if (!empty($el['elements']) && is_array($el['elements'])) { $walk($el['elements']); }
}
};
$walk($elements);
return $stripped;
}
private static function inject_global_typography($global_typography) {
if (empty($global_typography) || !is_array($global_typography)) {
return 0;
}
$kit_id = self::active_kit_id();
if (!$kit_id) {
return 0;
}
$settings = get_post_meta($kit_id, '_elementor_page_settings', true);
if (!is_array($settings)) {
$settings = array();
}
$existing = (isset($settings['custom_typography']) && is_array($settings['custom_typography']))
? $settings['custom_typography'] : array();
$have = array();
foreach ($existing as $row) {
if (isset($row['_id']) && is_string($row['_id']) && '' !== $row['_id']) {
$have[$row['_id']] = true;
}
}
$plain_keys = array('typography_font_family', 'typography_font_weight',
'typography_text_transform', 'typography_font_style', 'typography_text_decoration');
$sized_keys = array('typography_font_size', 'typography_line_height',
'typography_letter_spacing', 'typography_word_spacing');
$units = array('px', 'em', 'rem', '%', 'vh', 'vw', 'custom');
$added = 0;
foreach ($global_typography as $p) {
if (!is_array($p) || !isset($p['_id']) || !is_string($p['_id']) || '' === $p['_id']) {
continue;
}
$id = sanitize_text_field($p['_id']);
if ('' === $id) {
continue;
}
if (isset($have[$id])) {
// ADDITIVE ONLY - but adoption is not. Review cycle 2 measured the trap: page A
// imports first and owns the 'Heading 2' row; page B, authored with a DIFFERENT
// uniform h2, arrives second - its row is skipped here, its widgets still carry
// refs to this id, and B's headings silently RENDER AS A's (Georgia-for-Verdana,
// on a real WordPress). The same customer-visible failure as the id collision,
// triggered by import order, and the readme promised the opposite.
//
// So on an id hit the incoming values are COMPARED to the stored row: identical
// (or a row the customer merely renamed) -> reuse is safe, refs stand. DIFFERENT
// -> this page must NOT adopt: the refs for this id are stripped from the
// template below, the widgets keep their own inline typography (rule 3: inline
// stays precisely so the template is self-sufficient), and the customer's
// existing preset - possibly their own edit - is never touched.
if (!self::typography_values_match($existing, $id, $p)) {
self::$typography_strip_reason = 'mismatch';
self::$typography_mismatched_ids[] = $id;
}
continue;
}
$row = array(
'_id' => $id,
'title' => (isset($p['title']) && is_string($p['title']) && '' !== $p['title'])
? sanitize_text_field($p['title']) : 'Imported',
'typography_typography' => 'custom',
);
foreach ($plain_keys as $k) {
foreach (array('', '_tablet', '_mobile') as $sfx) {
if (isset($p[$k . $sfx]) && is_string($p[$k . $sfx]) && '' !== $p[$k . $sfx]) {
$row[$k . $sfx] = sanitize_text_field($p[$k . $sfx]);
}
}
}
foreach ($sized_keys as $k) {
foreach (array('', '_tablet', '_mobile') as $sfx) {
if (!isset($p[$k . $sfx]) || !is_array($p[$k . $sfx])) {
continue;
}
$v = $p[$k . $sfx];
if (!isset($v['size']) || !is_numeric($v['size'])) {
continue; // malformed field: drop the FIELD, keep the row
}
// An unknown unit DROPS THE FIELD, exactly as documented. The first version
// silently coerced it to px — a 12pt size would have written 12px — while
// three commit messages said 'dropped'. The engine emits px/em/rem/%/vw/vh
// today; anything else is a representation this build does not understand,
// and guessing is how a wrong size lands in a SITE-WIDE preset.
if (!isset($v['unit']) || !is_string($v['unit']) || !in_array($v['unit'], $units, true)) {
continue;
}
$row[$k . $sfx] = array('unit' => $v['unit'], 'size' => 0 + $v['size'], 'sizes' => array());
}
}
$existing[] = $row;
$have[$id] = true;
$added++;
}
if ($added > 0) {
$settings['custom_typography'] = $existing;
update_post_meta($kit_id, '_elementor_page_settings', wp_slash($settings)); // slashed: see inject_global_colors (#319)
self::flush_elementor_css();
}
return $added;
}
private static function inject_global_fonts($global_fonts) {
if (empty($global_fonts) || !is_array($global_fonts)) {
return 0;
}
$kit_id = self::active_kit_id();
if (!$kit_id) {
return 0; // No usable kit (absent, or the option names a post that is gone).
}
$settings = get_post_meta($kit_id, '_elementor_page_settings', true);
if (!is_array($settings)) {
$settings = array();
}
$existing = (isset($settings['custom_typography']) && is_array($settings['custom_typography']))
? $settings['custom_typography'] : array();
// Same rule as the palette: a row the customer owns but that carries no `_id` is carried
// through untouched rather than dropped when the list is rebuilt.
$by_id = array();
$order = array();
$unkeyed = array();
foreach ($existing as $f) {
// Same as the palette: a repeated id keeps its own row rather than being collapsed
// onto the last one that carried it.
if (isset($f['_id']) && is_string($f['_id']) && '' !== $f['_id']
&& !isset($by_id[$f['_id']])) {
$by_id[$f['_id']] = $f; $order[] = $f['_id'];
} else {
$unkeyed[] = $f; $order[] = array('__unkeyed' => count($unkeyed) - 1);
}
}
$kitmalformed = 0;
$kitfontcap = max(0, count($global_fonts) - self::MAX_KIT_FONTS);
$global_fonts = array_slice($global_fonts, 0, self::MAX_KIT_FONTS);
$added = 0;
foreach ($global_fonts as $f) {
if (!isset($f['_id']) || !isset($f['typography_font_family'])) {
$kitmalformed++;
continue;
}
// `sanitize_text_field()` returns '' for an array, so an array id or an array family
// arrives here as an empty string and the check below refuses it. An earlier version
// also had an `is_string()` pair in front of this — review ablated it and the suite
// stayed green, because it could never fire first. A guard that cannot go red is
// decoration; the empty-string check is the one doing the work.
$id = sanitize_text_field($f['_id']);
$family = sanitize_text_field($f['typography_font_family']);
if ('' === $id || '' === $family) {
$kitmalformed++;
continue;
}
// Same rule, same reason: `_id` is a hash of the family, so an existing id can only
// hold the family we already sent. Skipping it cannot lose information and stops us
// overwriting a font the customer has since changed.
if (isset($by_id[$id])) { continue; }
$by_id[$id] = array(
'_id' => $id,
'title' => isset($f['title']) && is_string($f['title'])
? sanitize_text_field($f['title']) : 'Imported',
'typography_typography' => 'custom',
'typography_font_family' => $family,
);
$order[] = $id;
$added++;
}
$merged = array();
foreach ($order as $id) {
$merged[] = is_array($id) ? $unkeyed[$id['__unkeyed']] : $by_id[$id];
}
$settings['custom_typography'] = $merged;
self::report_drops(array('kitfontcap' => $kitfontcap, 'kitmalformed' => $kitmalformed));
update_post_meta($kit_id, '_elementor_page_settings', wp_slash($settings)); // slashed: see inject_global_colors (#319)
if ($added > 0) { self::flush_elementor_css(); } // see the note in inject_global_colors
return $added;
}
/**
* Save the converted template as an Elementor library item.
*
* @param array $template The full Elementor template (content, page_settings, etc.)
* @param string $title Template title
* @param array $fonts List of font families used
* @param array $global_colors Optional Global Colors palette to inject into the kit.
* @param array $global_fonts Optional Global Fonts list to inject into the kit.
* @return int|WP_Error Post ID on success, WP_Error on failure
*/
/**
* Tell the API the template actually landed, so the conversion may be counted.
*
* The server only advances a quota when this call arrives. That is the whole point:
* /api/convert with `wants_confirm` writes its log row with confirmed_at = NULL, so a
* conversion that dies after the API responded — a security plugin eating the response,
* a PHP fatal mid-save — costs the customer nothing. Before this existed the free tier
* was charged on server success alone, which permanently locked out any prospect whose
* single trial import failed downstream.
*
* Non-fatal by design. The template is already saved by the time we get here, so a
* confirm failure must never surface to the user; it fails in the customer's favour
* (the conversion simply goes uncounted).
*
* @param string $conversion_id UUID from the convert response. Empty on an older API
* that already counted at convert time — then we send nothing.
* @param string $domain Site domain; the server matches it against the logged row.
* @param bool $was_free True when no licence key was used, so the free-tier
* counter is the one that advances.
* @return bool True only when the API acknowledged the confirm.
*/
private static function confirm_conversion($conversion_id, $domain, $was_free) {
if (empty($conversion_id)) {
return false;
}
$response = wp_remote_post(HTEL_License::get_api_url() . '/api/conversion-confirm', array(
'timeout' => 15,
'headers' => array('Content-Type' => 'application/json'),
'body' => wp_json_encode(array(
'conversion_id' => $conversion_id,
'domain' => $domain,
'was_free' => (bool) $was_free,
)),
));
if (is_wp_error($response)) {
return false;
}
return wp_remote_retrieve_response_code($response) === 200;
}
/** Result of the last import_pro_nav_menus() run — feeds the customer note. */
private static $last_menu_stats = array('found' => 0, 'created' => 0, 'fallbacks' => 0, 'reused' => 0);
/**
* Reuses inside ONE import run.
*
* `create_wp_menu_from_payload()` cannot record a reuse on `$last_menu_stats` directly:
* `import_pro_nav_menus()` builds a LOCAL `$stats` and the caller REPLACES the whole static
* array with it, so the increment would be silently discarded and the customer told nothing
* on exactly the path this counter exists to announce.
*/
private static $menu_reused_in_run = 0;
/**
* Menu items dropped by the last `import_pro_nav_menus()` run, including the children that
* went with them. It lives here rather than in a local because the counting happens inside
* `create_wp_menu_from_payload()` and the reporting happens in its caller — and threading a
* reference through a recursive closure to say one number was not worth the shape.
*/
private static $last_nav_items_dropped = 0;
/** Total items in a menu payload subtree, children included. */
private static function count_menu_items($item) {
$n = 0;
if (!empty($item['children']) && is_array($item['children'])) {
foreach ($item['children'] as $child) {
$n += 1 + self::count_menu_items($child);
}
}
return $n;
}
/** Remote-image import stats for the last conversion: found / imported / failed. */
private static $last_remote_images = array('found' => 0, 'imported' => 0, 'failed' => 0);
/**
* Consume `__hte_menu` payloads (emitted when the converter was told this
* site has a usable Pro nav-menu widget): create a real WordPress menu per
* payload and point the widget at it. When the widget is NOT usable, rewrite
* it to the carried fallback markup.
*
* THE FALLBACK BRANCH IS NOT REACHABLE THE WAY THIS COMMENT USED TO CLAIM. It named two
* causes — "availability changed between convert and import" and "the payload arrived at a
* free site" — and review showed both are impossible on the only path that produces the
* payload: the engine emits `__hte_menu` solely when the plugin reported Pro as usable, and
* the import re-checks the SAME function in the SAME request. So `$usable` is true whenever a
* payload exists, and `create_wp_menu_from_payload()` always runs. What remains reachable is a
* THROW inside that call, or `wp_create_nav_menu` failing — real, but neither of the two
* things the comment asserted. It also means all three nav counters need Elementor Pro to
* fire at all, which the QA rig does not have.
* Non-fatal by design: a menu problem must never fail the whole import.
*/
private static function import_pro_nav_menus(array &$elements, $title) {
$stats = array('found' => 0, 'created' => 0, 'fallbacks' => 0, 'reused' => 0);
self::$menu_reused_in_run = 0;
$navfallback = 0;
$navempty = 0;
// RESET ON ENTRY TOO. It is reset after reporting, so a throw between the walk and the
// report would leak the count into the next conversion in the same PHP process.
self::$last_nav_items_dropped = 0;
$usable = HTEL_Elementor_Check::is_pro_nav_menu_usable();
$walk = function (&$els) use (&$walk, &$stats, $usable, $title, &$navfallback, &$navempty) {
foreach ($els as &$el) {
if (isset($el['widgetType']) && $el['widgetType'] === 'nav-menu'
&& !empty($el['settings']['__hte_menu']) && is_array($el['settings']['__hte_menu'])) {
$stats['found']++;
$payload = $el['settings']['__hte_menu'];
unset($el['settings']['__hte_menu']);
$menu_id = 0;
if ($usable) {
try {
$reused_before = (int) self::$menu_reused_in_run;
$menu_id = self::create_wp_menu_from_payload($payload, $title);
} catch (\Throwable $e) {
$menu_id = 0;
}
}
if ($menu_id > 0) {
// VERIFIED against live Pro 4.2.2 (wp71, 2026-08-19): the Nav Menu
// widget's `menu` select is keyed by the menu's SLUG — introspected
// from the real control's default with a menu present. (First cut
// wired the term id; that was the flagged-unverified guess, and it
// was wrong.)
$menu_obj = wp_get_nav_menu_object($menu_id);
$el['settings']['menu'] = $menu_obj ? (string) $menu_obj->slug : (string) $menu_id;
// CREATED or REUSED — and they are different sentences to the customer.
// This line used to fire on both, because reuse also returns a valid id.
// The effect: `reused > 0` could never occur without `created > 0`, the
// reuse branch of the success note was DEAD, and a repeat import told the
// customer a permanent object had been created when none had. Review
// measured it end to end; my own runtime test missed it because I fed it
// `created=0, reused=1`, a shape this counter could never emit.
if (self::$menu_reused_in_run > $reused_before) {
$stats['reused']++;
} else {
$stats['created']++;
}
} else {
// COUNTED, AND THE EMPTY CASE SEPARATELY. `$stats` is only surfaced on
// header/footer conversions (ajax_convert:425) — on an ordinary page this
// was silent, and `fallback_html` can be '' (the engine emits
// `String(fallback.settings?.html || '')`), which leaves an EMPTY widget
// where the navigation was. That is the one entry on the #152 list that
// loses visible content outright.
$fallback_html = isset($payload['fallback_html']) ? (string) $payload['fallback_html'] : '';
if (trim($fallback_html) === '') { $navempty++; } else { $navfallback++; }
$el['widgetType'] = 'html';
$el['settings'] = array(
'html' => $fallback_html,
'_element_width' => 'auto',
// `_css_classes`, NOT `css_classes`. Elementor renders a WIDGET's
// custom classes from the underscored key (see the builder's own note);
// the engine writes the un-underscored one, so reading it is right and
// writing it back threw the classes away on the branch whose whole
// claim is "they render". The SVG fallback four lines away already
// keeps `_css_classes` correctly.
'_css_classes' => isset($el['settings']['css_classes'])
? $el['settings']['css_classes']
: (isset($el['settings']['_css_classes']) ? $el['settings']['_css_classes'] : ''),
);
$stats['fallbacks']++;
}
}
if (!empty($el['elements']) && is_array($el['elements'])) {
$walk($el['elements']);
}
}
};
$walk($elements);
self::report_drops(array('navfallback' => $navfallback, 'navempty' => $navempty,
'navitem' => self::$last_nav_items_dropped));
self::$last_nav_items_dropped = 0;
$stats['reused'] = (int) self::$menu_reused_in_run;
return $stats;
}
/**
* Create a WordPress menu from an __hte_menu payload. Menus are WP core —
* this works (and is tested) on free installs; only the RENDERING widget
* needs Pro. Returns the menu term id.
*/
/**
* label|url|depth of every item, in order — the identity of a navigation.
*/
private static function menu_payload_signature(array $items) {
$parts = array();
$walk = function ($list, $depth) use (&$walk, &$parts) {
foreach ((array) $list as $it) {
if (!is_array($it)) { continue; }
// SIGN WHAT WILL BE STORED, NOT WHAT ARRIVED.
//
// The write path puts every item through `sanitize_text_field()` and
// `esc_url_raw()`, and the comparison reads those stored values back. Signing
// the RAW payload compared two different representations:
// `esc_url_raw('about.html')` is `'http://about.html'`, so a static-site nav
// could never match itself and every conversion minted another menu — review
// measured 5 conversions of one page leaving 5 permanent menus, and 2 of 12
// real corpus navigations failing to dedupe. Whitespace in a label and spaces
// in a `tel:` did the same.
// A NAV THAT UNLINKS THE CURRENT PAGE IS A DIFFERENT NAVIGATION, and dedupe
// cannot honestly say otherwise — see #177's disclosure. `/` on the home page
// and `#` on the about page really are different item lists, so a site using
// that pattern gets one menu per page. I tried normalising the marker away and
// it does not converge: the SAME item still signs by its url on every other
// page. Anything that did converge would have to ignore urls entirely, which
// would merge two different sites' `Home/About/Contact` navigations into one.
// Measured exposure is in #177; refining this a fifth time is not the answer.
$parts[] = $depth
. '|' . trim((string) sanitize_text_field(isset($it['label']) ? $it['label'] : ''))
. '|' . trim((string) esc_url_raw(isset($it['url']) ? $it['url'] : '#'));
if (isset($it['children']) && is_array($it['children'])) {
$walk($it['children'], $depth + 1);
}
}
};
$walk($items, 0);
return empty($parts) ? '' : md5(implode(chr(10), $parts));
}
/**
* The same signature, computed from a menu that already exists in this site.
*/
private static function existing_menu_signature($term_id) {
$objects = wp_get_nav_menu_items((int) $term_id, array('post_status' => 'publish'));
if (!is_array($objects)) { return ''; }
$byId = array();
foreach ($objects as $o) { $byId[(int) $o->ID] = $o; }
$depthOf = function ($o) use ($byId) {
$d = 0; $p = (int) $o->menu_item_parent;
while ($p && isset($byId[$p]) && $d < 10) { $d++; $p = (int) $byId[$p]->menu_item_parent; }
return $d;
};
$parts = array();
foreach ($objects as $o) {
$parts[] = $depthOf($o) . '|' . trim((string) $o->title) . '|' . trim((string) $o->url);
}
return empty($parts) ? '' : md5(implode(chr(10), $parts));
}
private static function create_wp_menu_from_payload(array $payload, $title) {
$base = sanitize_text_field((isset($payload['name']) && $payload['name'] !== '') ? $payload['name'] : 'Imported menu');
$name = $base . ' — ' . sanitize_text_field($title);
$items_for_sig = (isset($payload['items']) && is_array($payload['items'])) ? $payload['items'] : array();
// MATCH BY THE NAVIGATION, NOT BY THE PAGE TITLE.
//
// The first version keyed reuse on 'Imported menu — ' . $title, so only converting the
// SAME page twice deduped. Ten pages of one site share one navigation and ten titles, so
// they produced ten identical menus — exactly the accumulation this code's own comment
// named as the thing to prevent, measured by review at 10 conversions -> 10 menus.
//
// A navigation IS its items, so the signature is the key. Scan every menu in the site:
// if one already holds exactly these items in this order and depth, it is the menu this
// conversion would have built. A different navigation never matches, so nothing is
// silently merged.
//
// WHAT HAPPENS TO A MENU THE CUSTOMER HAS EDITED, measured on a real WordPress rather
// than assumed: it is ADOPTED. The signature we stored is what we match on, and we do
// not re-read the items, so renaming, reordering or repurposing an imported menu does
// not stop a later conversion pointing at it.
//
// An earlier version of this comment claimed the opposite — that an edited menu 'stops
// matching and a new one is created'. That was true of the re-derivation this replaced,
// and storing the signature made it impossible; review caught the sentence surviving the
// rewrite. The behaviour is deliberate: this menu is one WE created and marked, so
// reusing it is reusing our own object rather than littering the customer's site with a
// duplicate on every conversion. The cost is that a repurposed menu gets wired to a
// later page whose HTML no longer matches it — disclosed in #177.
// An EMPTY navigation is not a menu. Without this a payload with no items minted a new
// empty menu on every call — three calls, three menus, none useful, none ever deleted.
if (empty($items_for_sig)) {
return 0;
}
// STORE THE SIGNATURE; NEVER RE-DERIVE IT.
//
// Deriving it by reading the menu back has now failed three review cycles running, each
// time on a different representation: the raw-vs-sanitised mismatch (cycle 3), a
// backslash that `wp_insert_post` consumes on write but `sanitize_text_field` keeps
// (cycle 4), and any site whose plugins filter `wp_setup_nav_menu_item` — one
// trailing-slash filter is enough. Every one of those minted a permanent menu on every
// conversion, forever.
//
// The mechanism was wrong, not the tuning, so this changes KIND: the signature we
// computed is written as term meta when the menu is created, and matched against that.
// WordPress cannot alter a value it never renders. Reading items back survives only as
// a fallback for menus made before this shipped.
$sig = self::menu_payload_signature($items_for_sig);
// A payload we cannot sign is a payload we cannot dedupe — and 'cannot dedupe' must
// mean 'do not write', not 'write every time'. Malformed items (strings, ints) skipped
// every part, produced an empty signature, bypassed the whole match and still created a
// menu on each call: the exact opposite of the empty-items guard above it.
if ($sig === '') {
return 0;
}
{
$all_menus = wp_get_nav_menus();
if (is_array($all_menus)) {
foreach ($all_menus as $m) {
$stored = get_term_meta((int) $m->term_id, '_htel_menu_sig', true);
$match = ($stored !== '' && $stored !== false)
? ($stored === $sig)
: (self::existing_menu_signature($m->term_id) === $sig);
if ($match) {
self::$menu_reused_in_run++;
return (int) $m->term_id;
}
}
}
}
$existing = wp_get_nav_menu_object($name);
if ($existing) {
// REUSE, DON'T MINT. Converting ten pages of one site used to leave ten menus —
// 'Imported menu — Home', then nine more with a random 4-character suffix — and
// nothing in this plugin ever deletes one (#177). Measured on the corpus after the
// Pro path moved to the default arm: 58 of 210 conversions create a menu, so an
// agency importing an afternoon's work accumulates them silently.
//
// If a menu of this name already holds EXACTLY these items, it is the same menu
// this conversion would have built: hand it back instead of duplicating it. The
// signature is label+url+depth in order, so a genuinely different navigation under
// a colliding name still gets its own menu and nothing is silently merged.
$name .= ' ' . wp_generate_password(4, false, false);
}
$menu_id = wp_create_nav_menu($name);
if (!is_wp_error($menu_id) && (int) $menu_id > 0) {
// Written BEFORE the items, so a partial write still carries the identity of what
// was attempted and a retry recognises it instead of minting a second menu.
update_term_meta((int) $menu_id, '_htel_menu_sig', $sig);
}
if (is_wp_error($menu_id)) {
return 0;
}
$items = (isset($payload['items']) && is_array($payload['items'])) ? $payload['items'] : array();
$add = function ($item, $parent_db_id) use (&$add, $menu_id) {
$db_id = wp_update_nav_menu_item($menu_id, 0, array(
'menu-item-title' => sanitize_text_field(isset($item['label']) ? $item['label'] : ''),
'menu-item-url' => esc_url_raw(isset($item['url']) ? $item['url'] : '#'),
'menu-item-type' => 'custom',
'menu-item-status' => 'publish',
'menu-item-parent-id' => (int) $parent_db_id,
));
if (is_wp_error($db_id)) {
// COUNTED — AND THE RETURN TAKES THE CHILDREN WITH IT. This closure recurses into
// `$item['children']` below, so one failed item silently removes its whole subtree.
// That is correct (a child with no parent is worse than no child) and it was
// completely silent, which is not.
self::$last_nav_items_dropped += 1 + self::count_menu_items($item);
return;
}
if (!empty($item['children']) && is_array($item['children'])) {
foreach ($item['children'] as $child) {
$add($child, $db_id);
}
}
};
foreach ($items as $item) {
$add($item, 0);
}
return (int) $menu_id;
}
/**
* The success note the customer reads after a conversion.
*
* EXTRACTED so it can be RUN in a test rather than grepped. Review's ship blocker was a
* sentence that existed in this file and could never reach a customer: the menu note sat
* inside the header/footer-only block, while the engine writes a Pro menu on ordinary page
* conversions too — 58 of 210 corpus pages, every one silent. A file-position assertion
* would not have caught that, and did not; only running the decision does.
*
* @param array $template the converted template (its `type` decides the site-part copy)
* @param array $menu_stats found/created/fallbacks/reused from import_pro_nav_menus()
* @return string the note, '' when there is nothing to say
*/
/**
* The COMPLETE success note: site-part copy + menu sentence + typography sentence, composed
* in one callable so a test can drive the whole thing - review cycle 3 found the wiring
* lines in ajax_convert had no gate that could go red (the exact shape of the menu-note
* lesson from the previous batch).
*/
public static function full_success_note(array $template, array $menu_stats) {
$note = self::build_success_note($template, $menu_stats);
$typo = self::typography_conflict_note();
if ('' !== $typo) {
$note = ('' === $note) ? $typo : ($note . ' ' . $typo);
}
return $note;
}
/** '' or one sentence when a page kept its own heading styles over an existing preset. */
/** Which strip fired: 'mismatch' (a differing preset exists) or 'nokit' (no usable kit). */
private static $typography_strip_reason = '';
public static function typography_conflict_note() {
if (empty(self::$typography_mismatched_ids)) {
return '';
}
// TWO truths, two sentences - review cycle 4 caught the kit-less branch borrowing the
// mismatch copy and telling a broken-kit customer to hunt a preset that does not exist.
if ('nokit' === self::$typography_strip_reason) {
return esc_html__('Note: no usable Elementor kit was found, so this page keeps its own heading styling instead of using Site Settings presets.', 'aitoel-html-importer');
}
return esc_html__('Note: Site Settings already holds a different style for some heading levels on this page, so the page keeps its own heading styling instead of adopting them.', 'aitoel-html-importer');
}
private static function build_success_note(array $template, array $menu_stats) {
$site_part_note = '';
$landed_type = isset($template['type']) && in_array($template['type'], array('header', 'footer'), true)
? $template['type'] : '';
$site_part_note = '';
if ($landed_type !== '') {
$type_label = ($landed_type === 'header')
? esc_html__('Header', 'aitoel-html-importer')
: esc_html__('Footer', 'aitoel-html-importer');
if (defined('ELEMENTOR_PRO_VERSION')) {
/* translators: %s: "Header" or "Footer" */
$site_part_note = sprintf(esc_html__('Imported as a %s template. Assign it site-wide under Templates → Theme Builder.', 'aitoel-html-importer'), $type_label);
} else {
$site_part_note = esc_html__('Imported as a template. The Theme Builder in Elementor Pro can assign it site-wide; without Pro, insert it at the top of your pages.', 'aitoel-html-importer');
}
if (((int) $menu_stats['fallbacks'] > 0 || !HTEL_Elementor_Check::is_pro_nav_menu_usable())
&& (int) $menu_stats['created'] === 0 && (int) $menu_stats['reused'] === 0) {
$site_part_note .= ' ' . esc_html__('Menu note: without Elementor Pro\'s Menu widget, your navigation was imported as regular links — fully working and editable. With Pro active, converting again produces a native menu widget.', 'aitoel-html-importer');
}
}
// A WRITE OUTSIDE THE TEMPLATE IS ALWAYS ANNOUNCED.
//
// This sentence used to live INSIDE the block above, which runs only for a header or
// footer template — i.e. only when the customer had ticked the checkbox. The engine now
// builds a Pro menu whenever it detects a page header, so on an ordinary page conversion
// the menu was created and NOTHING said so: measured, 58 of 210 corpus pages on the Pro
// default arm, every one silent. A menu is a permanent object in the customer's site that
// this plugin never deletes, so it is announced wherever it is written.
// Defend the keys: an incomplete stats array raised five `Undefined array key` notices.
$menu_stats_all = array_merge(
array('found' => 0, 'created' => 0, 'fallbacks' => 0, 'reused' => 0), $menu_stats);
$menu_note = '';
if ((int) $menu_stats_all['created'] > 0) {
$menu_note = esc_html__('A WordPress menu was created from your navigation (Appearance → Menus) and connected to the Pro menu widget.', 'aitoel-html-importer');
} elseif ((int) $menu_stats_all['reused'] > 0) {
$menu_note = esc_html__('Your navigation was connected to a WordPress menu that already matched it (Appearance → Menus) — no duplicate was created.', 'aitoel-html-importer');
} elseif ((int) $menu_stats_all['fallbacks'] > 0) {
// THE SAME CLASS AS THE CYCLE-2 BLOCKER, ONE BRANCH FURTHER OUT. A Pro conversion
// whose menu widget had to fall back to plain links said nothing at all on an
// ordinary page: the only sentence covering it lived in the site-part block. The
// drop is recorded by `report_drops()`, which writes to error_log, never to a customer.
$menu_note = esc_html__('Your navigation was imported as regular links rather than a menu widget — fully working and editable.', 'aitoel-html-importer');
}
if ($menu_note !== '') {
$site_part_note = ($site_part_note === '') ? $menu_note : ($site_part_note . ' ' . $menu_note);
}
// ICONS KEPT AS MARKUP ARE ANNOUNCED. Exactly the nav-menu class one surface further out:
// the fallback was real, deliberate and completely silent, so a page could arrive with
// fifteen raw HTML widgets and the only available conclusion was that we convert badly.
// The two causes get different sentences because only one is actionable — telling someone
// to switch on a setting they already have on is worse than saying nothing.
$svg_note = '';
if (self::$svg_held > 0) {
$svg_note = sprintf(
/* translators: %d: number of icons */
_n(
'%d icon was kept as markup rather than an editable image, because native SVG icon import is switched off on this site. It renders exactly as designed. A site administrator can switch it on; see the plugin FAQ for the one-line filter.',
'%d icons were kept as markup rather than editable images, because native SVG icon import is switched off on this site. They render exactly as designed. A site administrator can switch it on; see the plugin FAQ for the one-line filter.',
self::$svg_held, 'aitoel-html-importer'),
self::$svg_held);
} elseif (self::$svg_write > 0) {
$svg_note = sprintf(
/* translators: %d: number of icons */
_n(
'%d icon could not be saved to your Media Library and was kept as markup instead. Icon import is switched on here, so this is a write failure rather than a setting — a security plugin blocking SVG uploads is the usual cause.',
'%d icons could not be saved to your Media Library and were kept as markup instead. Icon import is switched on here, so this is a write failure rather than a setting — a security plugin blocking SVG uploads is the usual cause.',
self::$svg_write, 'aitoel-html-importer'),
self::$svg_write);
}
if ($svg_note !== '') {
$site_part_note = ($site_part_note === '') ? $svg_note : ($site_part_note . ' ' . $svg_note);
}
return $site_part_note;
}
private static function save_elementor_template($template, $title, $fonts, $global_colors = array(), $global_fonts = array(), $global_typography = array()) {
// Inject the Global Colors palette into the active kit FIRST, so the template's
// __globals__ references resolve the moment the page renders. This is ATOMIC with
// the page from the user's perspective (same request). ADDITIVE ONLY — it never removes
// or modifies anything the kit already holds, ours or the customer's (dedup by _id, and
// our ids are `hte`-prefixed so they cannot collide with Elementor's or the user's).
// A dangling ref would render Elementor's DEFAULT color, not the inline value, so
// this MUST run whenever the template carries our refs. No-op when the array is empty.
// EACH LIST IS CALLED ON ITS OWN. Both injectors no-op on an empty array, so there is no
// outer guard — and there must not be one. The previous version wrapped BOTH calls in
// `if (!empty($global_colors))`, so a page yielding fonts and no colours wrote nothing.
//
// That defect survived a round of review because the fix was written as a COMMENT four
// lines above the guard and the guard itself was never moved, and because the test for it
// called `inject_global_fonts` through Reflection — below the level where the bug lived.
// The test now goes through THIS function, which is what the AJAX handler calls.
$colors_added = self::inject_global_colors($global_colors);
self::inject_global_fonts($global_fonts);
// #175: full typography presets - same contract as the palette. The template's
// __globals__ typography refs render the kit DEFAULT until these land, so the
// injection is atomic with the page. Additive only, own call, no outer guard
// (the lesson four lines up applies verbatim).
self::$typography_mismatched_ids = array();
self::$typography_strip_reason = '';
// P4 (review cycle 3): if the kit died between should_globalize() and here, the
// injector no-ops, nothing records a mismatch, and the refs would DANGLE - rendering
// the kit default (measured: Roboto 32 over authored Verdana 22). No kit means NO ref
// may survive: treat every incoming id as mismatched so the strip below removes them
// all and the page renders exactly as authored - the same fail-safe as a value
// mismatch. Logged, unlike the colour channel, because silent was the finding.
if (!empty($global_typography) && is_array($global_typography) && !self::active_kit_id()) {
foreach ($global_typography as $p9) {
if (isset($p9['_id']) && is_string($p9['_id'])) { self::$typography_mismatched_ids[] = $p9['_id']; }
}
self::$typography_strip_reason = 'nokit';
error_log('[aitoel] typography presets arrived with no usable kit; refs stripped, page keeps inline styles');
}
self::inject_global_typography($global_typography);
// A ref that survived a value MISMATCH would silently restyle this page with another
// import's preset (review cycle 2, measured). Strip those refs from the content BEFORE
// it is written; the widgets keep their inline typography and render as authored.
if (!empty(self::$typography_mismatched_ids) && isset($template['content']) && is_array($template['content'])) {
self::strip_mismatched_typography_refs($template['content']);
}
// A page can carry hundreds of `__globals__` colour references, and every one of them
// renders Elementor's DEFAULT colour — not the author's — if the palette never reached the
// kit. `inject_global_colors()` returns 0 both when there is nothing to do and when there
// is no active kit, so a page could import with its colours silently replaced. The request
// side now refuses to ask for globalize without a kit, so this should be unreachable; it
// stays because "should be" is not a guarantee across a plugin/API version skew.
// THE CONDITION IS "DID THE PALETTE LAND", not "is the option empty". A stale kit id keeps
// the option truthy, which is exactly the case where the page comes back recoloured and
// the log was silent.
if (!empty($global_colors) && 0 === $colors_added && !self::active_kit_id()) {
error_log('[aitoel] Global Colors arrived but there is no usable Elementor kit (the '
. 'active-kit option is empty or names a post that no longer exists); the palette '
. 'was not added and any global colour references will render Elementor defaults.');
}
// Create the library post
// #144 — INSERT AS A DRAFT, PUBLISH ONLY ONCE THE CONTENT IS WRITTEN.
//
// The passes below need this post's id, so they cannot run before the insert. What they
// CAN be denied is the ability to leave a published, empty template behind: review
// measured ~14x the payload in peak memory inside this function, which on a 128M host is
// a fatal, and it fires on exactly the pages the corpus says carry 5,883 KB of font. The
// customer was left with a template in their library that opens to nothing.
//
// A draft plus a shutdown guard closes both halves: nothing is published until
// `_elementor_data` exists, and a fatal — including memory exhaustion, which DOES run
// shutdown functions — removes the orphan rather than leaving it.
$post_id = wp_insert_post(array(
// Slashed (#319): wp_insert_post() unslashes, and `$title` is already unslashed, so a
// title "AC\DC" was saved as "ACDC".
'post_title' => wp_slash($title),
'post_status' => 'draft',
'post_type' => 'elementor_library',
));
if ($post_id && !is_wp_error($post_id)) {
// NARROWER THAN THE DOCBLOCK ABOVE IMPLIES: this removes a draft whose
// `_elementor_data` is EMPTY. A fatal in the window between that meta being written
// and the publish leaves a draft WITH content, which this will not touch — correctly,
// since it may be recoverable, but the sentence "removes the orphan on a fatal" was
// broader than the code.
//
// UNMEASURED BY THE SUITE, DELIBERATELY RECORDED AS SUCH. Removing this reddens
// nothing — a fatal cannot be raised inside a test run without ending it — yet review
// demonstrated in a subprocess that it changes behaviour: ablated leaves 1 orphan
// draft on both E_ERROR and real memory exhaustion, pristine leaves 0. A kernel
// OOM-kill is out of scope; nothing runs then. It is kept because the cost is one
// callback and the failure it catches is invisible — not because it is covered.
register_shutdown_function(function () use ($post_id) {
$e = error_get_last();
if (!$e || !in_array($e['type'], array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR), true)) {
return; // a clean exit; the normal path has already published or not
}
$post = get_post($post_id);
if ($post && $post->post_status === 'draft'
&& $post->post_type === 'elementor_library'
&& '' === (string) get_post_meta($post_id, '_elementor_data', true)) {
wp_delete_post($post_id, true);
}
});
}
if (is_wp_error($post_id)) {
return $post_id;
}
// Decompose standalone SVG icons emitted by the converter (svgIcons option): each is a native
// Image widget carrying a sanitized `__hte_svg` payload. Upload the SVG to the Media Library
// and wire the Image url/id — or, when SVG hosting is disabled (default) or upload fails,
// fall back to an inline-SVG HTML widget so it renders exactly like today. See import_svg_icons.
// #283: this site decides which Heading Title styles move into the page stylesheet (see
// apply_title_styles). FIRST, while the content is exactly what the engine recorded: the
// passes below rewrite `data:` URIs anywhere in the content, the stylesheet widget included.
$template = self::apply_title_styles($template);
$content = self::import_svg_icons($template['content'], $post_id);
// Pro nav-menu payloads: create the real WordPress menu(s) and wire the
// widget, or rewrite to the carried fallback markup when the widget is
// not genuinely usable here (see is_pro_nav_menu_usable — never render
// the free tier's upsell stub in a customer's header).
self::$last_menu_stats = self::import_pro_nav_menus($content, $title);
// Host any embedded data: URI images (self-contained / "Save Page As" sources) as real
// files in this site's Media Library, rewriting the data: URIs to their URLs. Without
// this, a self-contained page imports blank: Elementor's template importer strips data:
// images and a multi-MB embedded page is too heavy to render. See host_embedded_images.
$content = self::host_embedded_images($content, $post_id);
// AND THE FONTS. 65.4% of the carried stylesheet across the corpus is base64
// @font-face payloads (#77); hosting them is what makes the converted page lighter
// than the source instead of heavier. Runs AFTER the images so both share the
// `_htel_source_hash` dedupe.
$content = self::host_embedded_fonts($content, $post_id);
// Pull REMOTE images into this site's Media Library. Measured on 60 real customer
// pages: 21 hotlinked image URLs across 10 pages (17%) — every one of them a picture
// that dies the day the source host removes it, renames it, or starts blocking
// hotlinks, on a page the customer believes is finished. Strictly bounded and
// strictly non-fatal: any failure leaves the original URL exactly as it was.
// Disable via `add_filter('htel_import_remote_images', '__return_false')`.
$content = self::import_remote_images($content, $post_id);
// Set Elementor data — wp_slash is essential because update_post_meta runs wp_unslash
$elementor_data = wp_json_encode($content);
// AND IF THAT ENCODE FAILS, DO NOT PUBLISH. Both hosting passes check this exact call and
// bail; this one wrote `false` into `_elementor_data` and published anyway — the empty
// published template #144 exists to prevent, reached without any fatal at all. Byte-
// identical in 1.3.35, so pre-existing rather than a regression, and no less wrong.
if ($elementor_data === false) {
// AND IT RETURNS AN ERROR, NOT THE POST ID. The first version of this guard returned
// `$post_id`, and the caller treats any non-WP_Error as success — so the customer was
// shown "Template created successfully!" with an edit link to a draft carrying no
// `_elementor_data` at all. That converted "published empty" into "success message plus
// empty draft", which is worse: the first is visibly broken, the second is a lie. The
// draft goes with it, because a template nobody can use is not worth leaving behind.
$reason = json_last_error_msg();
error_log('[aitoel] the converted page could not be encoded for Elementor (JSON error: '
. $reason . '). Nothing was added to your library.');
wp_delete_post($post_id, true);
return new WP_Error(
'encode_failed',
sprintf(
/* translators: %s: the JSON error reported by PHP */
__('The converted page could not be encoded for Elementor (%s). Nothing was added to your library.', 'aitoel-html-importer'),
$reason
)
);
}
update_post_meta($post_id, '_elementor_data', wp_slash($elementor_data));
// Site-part landing (header mode, task #73): the API's envelope carries type
// "header"/"footer" when the customer ticked "I'm converting a header or footer".
// Landing with the matching template type is what makes Elementor Pro's Theme
// Builder treat it as an assignable site part. Anything unrecognized (old API
// responses, raw values) falls back to today's 'page' behavior, byte-identical.
$tpl_type = isset($template['type']) && in_array($template['type'], array('header', 'footer'), true)
? $template['type'] : 'page';
// Set Elementor meta
update_post_meta($post_id, '_elementor_edit_mode', 'builder');
update_post_meta($post_id, '_elementor_template_type', $tpl_type);
update_post_meta($post_id, '_elementor_version', '3.21.0');
if ($tpl_type === 'page') {
// Use Elementor Canvas template — removes theme header/footer/title
// so the converted page renders exactly as designed. Site parts are not
// pages, so they get no page template at all.
update_post_meta($post_id, '_wp_page_template', 'elementor_canvas');
}
// Set page settings if present
// Slashed, as `_elementor_data` is above: update_post_meta() unslashes, and a Custom CSS sent
// with `.md\:flex` or `content:"\201C"` was stored without its backslashes (#319).
if (!empty($template['page_settings']) && is_array($template['page_settings'])) {
update_post_meta($post_id, '_elementor_page_settings', wp_slash($template['page_settings']));
}
// Generate the post CSS FOR REAL. The previous code hand-wrote an _elementor_css meta
// with status:'' purely to carry the detected Google Fonts — but a present-yet-empty
// meta tells Elementor CSS state already exists, so it never builds the file, and the
// imported template renders with NO settings-derived styling (padding/colors/typography)
// until a manual Regenerate CSS. Observed empirically on Elementor 4.2.2 (V4 scope,
// 2026-08-09; elementor#35934) and the standing suspect for historic "messed-up import"
// reports on newer Elementor.
if (class_exists('\Elementor\Core\Files\CSS\Post')) {
$post_css = \Elementor\Core\Files\CSS\Post::create($post_id);
$post_css->update();
}
// Merge the converter-detected fonts INTO the generated meta rather than clobbering it:
// Elementor only discovers fonts declared through typography controls, while ours come
// from the source page's tags — without this merge, Google Fonts stop loading.
$generated_meta = get_post_meta($post_id, '_elementor_css', true);
if (!is_array($generated_meta)) {
// Elementor unavailable (guarded above) — keep the legacy shape so fonts still load.
$generated_meta = array(
'time' => time(),
'icons' => array(),
'dynamic_elements_ids' => array(),
'status' => '',
);
}
$generated_fonts = isset($generated_meta['fonts']) && is_array($generated_meta['fonts'])
? $generated_meta['fonts'] : array();
$generated_meta['fonts'] = array_values(array_unique(array_merge($generated_fonts, array_values($fonts))));
// Slashed (#319): this is the meta Elementor just wrote, read back, with the page's CSS in it
// when the site prints CSS inline; written raw, WordPress stripped the backslashes it holds.
update_post_meta($post_id, '_elementor_css', wp_slash($generated_meta));
// Set template type taxonomy (matches the meta above — header/footer/page)
wp_set_object_terms($post_id, $tpl_type, 'elementor_library_type');
// #144 — PUBLISH LAST, once this is a usable Elementor document and not merely a post
// with content. The first version published as soon as `_elementor_data` existed, four
// statements before `_elementor_edit_mode`; review measured that on Elementor 4.2.2 a
// template in that window reports `is_built_with_elementor() === false` and opens as a
// bare Container. A fatal there left exactly the artefact this change exists to prevent,
// and the shutdown guard could not remove it because it was no longer a draft.
wp_update_post(array('ID' => $post_id, 'post_status' => 'publish'));
return $post_id;
}
/**
* Download remotely-hosted images referenced by the template into the Media Library and
* repoint the template at the local copies.
*
* Bounded on purpose — an import must never hang or fail because someone else's image host
* is slow: at most HTEL_REMOTE_IMAGE_MAX files, a per-file download timeout, and a total
* wall-clock budget. Anything not fetched inside those bounds keeps its original URL, which
* is exactly the behaviour customers have today. Images already served by this site are
* skipped, as are non-image URLs.
*
* @param array $content Elementor content tree.
* @param int $post_id Post the images are attached to.
* @return array Content, rewritten where a download succeeded.
*/
/**
* The absolute URLs held in the image fields the engine writes: an Image widget's `image`, a
* container's `background_image` and `background_overlay_image`, and a video's poster
* `image_overlay`. Links and video URLs are not among them.
*/
private static function image_field_urls($elements) {
$urls = array();
foreach ((array) $elements as $el) {
foreach (array('image', 'background_image', 'background_overlay_image', 'image_overlay') as $key) {
$url = isset($el['settings'][$key]['url']) ? $el['settings'][$key]['url'] : null;
if (is_string($url) && preg_match('#^https?://#i', $url)) {
$urls[] = $url;
}
}
if (isset($el['elements'])) {
$urls = array_merge($urls, self::image_field_urls($el['elements']));
}
}
return $urls;
}
/**
* Each URL replaced where it stands whole in the text. It ends where a URL ends in CSS or
* markup — a quote, a bracket, a comma, white space, an escaped quote (`"`) — and not where
* a URL goes on: a path, a query, an `&`. Nor is it replaced inside another URL.
*
* @param string $text
* @param array $urls from => to
* @return string
*/
private static function replace_whole_urls($text, $urls) {
$url_char = 'A-Za-z0-9._\~:\/?#\[\]@!$*+=%\x80-\xFF-';
foreach ($urls as $from => $to) {
if (strpos($text, $from) === false) {
continue;
}
$text = preg_replace_callback(
'~(? 'attachment', 'post_status' => 'inherit',
'posts_per_page' => -1, 'fields' => 'ids', 'post_parent' => $post_id));
$known = is_array($known) ? array_flip($known) : array();
try {
foreach ($urls as $url) {
if ($count >= $max_files || (microtime(true) - $started) > $budget) {
// COUNTED. This break silently abandoned every remaining image on a page over the
// 40-file cap or the 25-second budget, and the customer was told nothing at all —
// the same class the two passes above it spent three review cycles closing, thirty
// lines to the left and never looked at.
$capped = count($urls) - $count;
break;
}
$count++;
$by_bytes = !isset($by_extension[$url]);
$tmp = download_url($url, $file_timeout);
if (is_wp_error($tmp)) {
$failed++;
continue;
}
$name = wp_basename(wp_parse_url($url, PHP_URL_PATH));
if ($name === '' || strpos($name, '.') === false) {
$name = 'image-' . $count . '.jpg';
}
if ($by_bytes) {
// Refused here when the bytes are not an image, even for a user WordPress would let
// upload a file of any type (unfiltered_upload).
$ext = self::image_extension_from_signature((string) file_get_contents($tmp, false, null, 0, 64));
if ($ext === '') {
wp_delete_file($tmp);
$failed++;
continue;
}
$base = wp_basename(wp_parse_url($url, PHP_URL_PATH));
$name = ($base !== '' ? sanitize_file_name($base) : 'image-' . $count) . '.' . $ext;
}
$file_array = array('name' => $name, 'tmp_name' => $tmp);
$attachment_id = media_handle_sideload($file_array, $post_id, null);
if (is_wp_error($attachment_id)) {
// wp_delete_file is the WP-blessed unlink wrapper (Plugin Check).
wp_delete_file($tmp);
$failed++;
continue;
}
// NO FILE LIST HERE ANY MORE. Both exits sweep by attachment snapshot, so this array
// was written and never read — and it cost a `get_attached_file()` per image to stay
// that way. Removed rather than left as furniture.
$wp_url = wp_get_attachment_url($attachment_id);
if (!$wp_url) {
$failed++;
continue;
}
if ($by_bytes) {
// Repointed wherever it stands whole — the field, and the page's own CSS carried
// beside it (`url(…)` in the styles block) — but never where it begins or sits
// inside a longer URL: the same photo at another size, a gallery page.
$whole[$url] = $wp_url;
continue;
}
// Rewrite the JSON-encoded form of the URL, which is what actually appears in
// $json — a raw string replace would miss any escaped solidus.
$map[trim(wp_json_encode($url), '"')] = trim(wp_json_encode($wp_url), '"');
}
self::$last_remote_images = array(
'found' => count($urls),
'imported' => count($map) + count($whole),
'failed' => $failed,
);
} catch (\Throwable $e) {
// NO `unwind_uploads` CALL HERE, AND ITS ABSENCE IS THE POINT. I wrote one, and
// ablating it reddened NOTHING: every entry in `$sideloaded` comes from
// `get_attached_file()`, so each one already HAS an attachment, and the snapshot
// sweep below deletes every attachment this call created along with its file.
// Two guards on one path make each other unmeasurable — the masked-guard shape,
// and I had just spent a cycle being caught by it. The snapshot is strictly the
// wider of the two: it also catches the orphan `media_handle_sideload` creates
// and throws over, which no list can have recorded yet.
self::sweep_new_attachments($post_id, $known);
throw $e; // swallowing it would be worse than the orphans; the caller decides
}
self::report_drops(array('remotefail' => $failed, 'remotecap' => $capped));
if (empty($map) && empty($whole)) {
return $content;
}
$decoded = json_decode(strtr($json, $map), true);
if (!is_array($decoded)) {
// AND THE THIRD PASS GETS THE SAME UNWIND THE OTHER TWO GOT. This branch returned the
// original content while every sideloaded file stayed in the customer's Media Library,
// referenced by nothing — the exact defect the image and font passes fixed, sitting
// unfixed next door because I enumerated two functions and called it the class.
// THE SAME SWEEP THE THROW PATH USES, NOT A NARROWER ONE. `unwind_uploads` skips any
// attachment whose `get_attached_file()` came back falsy, while the line below says
// "No files were left behind" without qualification. Two exits from one function
// deserve one mechanism, and the wider of the two is the honest choice.
// ONE MECHANISM FOR BOTH EXITS. There was an `unwind_uploads` call here as well and
// ablating it reddened NOTHING — the sweep below already covered it. That is the
// masked-guard shape for the second time in two cycles, in my own fix for the
// first one. Removed; the sweep is the single measured defence on both paths.
self::sweep_new_attachments($post_id, $known);
self::report_drops(array('reverted_remote' => 1));
return $content;
}
array_walk_recursive($decoded, function (&$value) use ($whole) {
if (is_string($value)) {
$value = self::replace_whole_urls($value, $whole);
}
});
return $decoded;
}
/** What the last import did with Heading Title styles (#283); read by the tests and the render gate. */
private static $last_title_styles = array('titles' => 0, 'moved' => 0, 'rules' => 0, 'reason' => '');
/**
* #283 — HEADING TITLE STYLES ARE DECIDED ON THIS SITE, AT IMPORT (George, 2026-09-18).
*
* The engine writes a styled word inside a Heading as ``, and a customer who
* opens the Heading reads that CSS in the Title field. Whether a declaration can leave the Title
* without changing the page depends on the WordPress that prints it, so the engine ships the
* template unchanged plus a record (`htel_title_styles`, see src/builder/title-styles.ts) and this
* site decides. A group of declarations moves into one rule in the page stylesheet only when this
* site's own `wp_kses_post` hands every member back unchanged, and the class that carries them on
* that tag. Such a declaration renders today,
* and renders the same from the rule, which outranks every selector in the page's own stylesheet
* as the inline style did (the engine ranks it above the most ids any of them carries, and never
* below five). A declaration the site drops stays inline, exactly as the engine wrote it. Both
* hold whether this Elementor prints the Title through `wp_kses_post` or unfiltered, so no
* version is consulted.
*
* FAIL CLOSED: the template is imported exactly as the engine sent it when the record is missing
* or malformed, when the stylesheet it names is not there, and when the importing user cannot
* save unfiltered HTML (when that user next saves the page, Elementor strips the ``. */
private static function title_styles_is_sheet($el, $id) {
return isset($el['id'], $el['widgetType'], $el['settings']['html']) && $el['id'] === $id
&& $el['widgetType'] === 'html' && is_string($el['settings']['html'])
&& substr($el['settings']['html'], -8) === '';
}
/** Whether the stylesheet widget the record names is anywhere in these elements. */
private static function title_styles_has_sheet($elements, $id) {
foreach ($elements as $el) {
if (self::title_styles_is_sheet($el, $id)) {
return true;
}
if (!empty($el['elements']) && is_array($el['elements']) && self::title_styles_has_sheet($el['elements'], $id)) {
return true;
}
}
return false;
}
/** The rules, appended inside the stylesheet widget just before its closing ``. */
private static function title_styles_append_html($elements, $id, $appended) {
foreach ($elements as $i => $el) {
if (self::title_styles_is_sheet($el, $id)) {
$elements[$i]['settings']['html'] = substr($el['settings']['html'], 0, -8) . $appended . '';
return $elements;
}
if (!empty($el['elements']) && is_array($el['elements'])) {
$elements[$i]['elements'] = self::title_styles_append_html($el['elements'], $id, $appended);
}
}
return $elements;
}
/**
* Replace embedded data: URI images in the Elementor content with real files in this site's
* Media Library.
*
* Why: a "Save Page As" / self-contained source embeds every image as a base64 data: URI.
* Elementor's UI template importer STRIPS data: images (KSES sanitisation) and a multi-MB
* embedded page is too heavy to render (PHP memory) — so such a page imports blank. Uploading
* each embedded image to the customer's OWN Media Library and rewriting the data: URI to its
* URL makes the page portable (survives re-export / manual import), lightweight, and gives the
* customer manageable media. Deduplicates repeated images.
*
* Fails open: on any decode/upload error the original data: URI is left in place (the plugin
* applies _elementor_data directly, which renders data: fine — only the import path strips it).
* Disable via `add_filter('htel_host_embedded_images', '__return_false')`.
*
* @param array $content Elementor content array ($template['content'])
* @param int $post_id Parent post — uploaded images are attached to it
* @return array Content with data: URIs swapped for Media Library URLs
*/
private static function host_embedded_images($content, $post_id) {
// JSON_UNESCAPED_SLASHES so base64 `/` chars stay raw (else the regex misses `\/`).
$json = wp_json_encode($content, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false || strpos($json, 'data:image/') === false) {
return $content; // nothing embedded (the fast path) OR wp_json_encode refused the document, which a 600-deep structure does. Nothing has been written at this point, so there is nothing to unwind and nothing to report — but the comment used to name only the first cause
}
if (!apply_filters('htel_host_embedded_images', true, $post_id)) {
return $content; // opt-out
}
if (!function_exists('wp_upload_bits')) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
if (!function_exists('wp_generate_attachment_metadata')) {
require_once ABSPATH . 'wp-admin/includes/image.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
}
$ext_map = array('jpeg' => 'jpg', 'svg+xml' => 'svg', 'x-icon' => 'ico', 'vnd.microsoft.icon' => 'ico');
$map = array();
$counter = 0;
$img_uploaded = array();
$unregistered = 0;
$tooheavy = 0;
$notimage = 0;
$svgempty = 0;
$cutshort = 0;
$toosmall = 0;
$writefail = 0;
$writewhy = '';
// Thumbnail generation dominates import time on shared hosting: every registered size
// (core + WooCommerce + theme can be 10+) is a GD re-encode PER IMAGE — an image-heavy
// page took minutes. The template references the full-size URL, so intermediate sizes
// only serve the Media Library UI; keep the core trio and skip the rest DURING our
// import only. Sites that want every size can opt out of the cap via this filter.
$limit_sizes = function ($sizes) {
$keep = array_intersect_key($sizes, array_flip(array('thumbnail', 'medium', 'large')));
return apply_filters('htel_import_image_sizes', $keep, $sizes);
};
// MY DOCBLOCK SAID THIS UNWIND RUNS ON "every exit that throws away the rewritten
// content", ON BOTH PATHS. It was false: this loop had no try/catch/finally at all, so a
// throw here left the file, the attachment AND `intermediate_image_sizes_advanced` still
// registered for the rest of the request. Review injected an `add_attachment` listener and
// measured 1 orphan file, 1 orphan attachment, filter still on — while the font path in
// the same run left 0/0. Not a regression (1.3.34 has no unwind anywhere); a claim that
// outran the code.
add_filter('intermediate_image_sizes_advanced', $limit_sizes, 99);
try {
// #143 — THE SAME TRUNCATION HOLE, IN DEPLOYED CODE. Without the terminator this match
// ends at the first character outside the base64 alphabet and the PREFIX is uploaded,
// while `str_replace` leaves the remainder glued onto the new URL. Review reproduced it
// in real WordPress: a 970-byte image written as 300 bytes, `url()` becoming
// `…/htel-12425-1.png WlpaWlpa…`. Worse here than on the font path, because the floor is
// 16 bytes rather than 64 and nothing downstream checks a signature at all.
// #144 — BEFORE THE MATCH SET, NOT AFTER. `preg_match_all` with PREG_SET_ORDER copies
// every match, and on a document this size that is the largest single allocation in the
// pass. Checking after it is checking after the fatal.
if (!self::pass_fits_this_host(strlen($json))) {
self::report_drops(array('skipmem' => 1), array('skipmem' => 'Encoded size '
. round(strlen($json) / 1048576, 1) . ' MB against PHP memory_limit '
. ini_get('memory_limit') . '.'));
return $content; // the `finally` below removes the size filter
}
$img_pattern = '#data:image/([a-zA-Z0-9.+\-]+);base64,' . self::b64_payload() . '#';
$img_found = preg_match_all($img_pattern, $json, $matches, PREG_SET_ORDER);
if (false === $img_found) {
self::report_drops(array('skipregex' => 1),
array('skipregex' => 'Engine code ' . preg_last_error() . '.'));
$img_found = 0;
}
if ($img_found) {
foreach ($matches as $m) {
$full = $m[0];
if (isset($map[$full])) {
continue; // dedupe: the same image is often reused across sections
}
$mime = strtolower($m[1]);
// #144 — ask the host before decoding, not after. The image pass runs FIRST and
// its JSON copy is still alive when the font pass runs, so an image that only
// just fits leaves nothing for the fonts.
if (self::too_big_for_this_host((int) (strlen($m[2]) * 3 / 4))) {
// COUNTED AND LOGGED BELOW. The first version refused in silence, on the one
// host class where a data:-heavy page is already recorded as OOMing at first
// CSS generation and rendering blank — so "the page renders" was not something
// this branch was entitled to assume, let alone leave unsaid.
$tooheavy++;
continue;
}
$bytes = base64_decode($m[2], true);
if ($bytes === false || strlen($bytes) < 16) {
$toosmall++;
continue; // invalid/tiny — leave as data:
}
// THE SECOND DEFENCE. If the regex ever mistakes a cut payload for a whole one
// again — and it has, three times — these bytes are still not written anywhere.
if (self::looks_truncated($bytes)) {
// THE BRANCH THE CORPUS ACTUALLY HITS, and it was silent while I was
// congratulating myself for instrumenting one with no corpus hits at all.
// #143 records SIX cut payloads across the corpus and every one of them
// leaves through here. Reporting the rare shape and not the common one is
// the instance, not the class.
$cutshort++;
continue; // half an image; keep the data: URI, which at least renders
}
// Cross-conversion dedupe: re-converting the same page (an iterate-and-retry
// workflow is normal) must not re-upload identical files and multiply the
// Media Library. Attachments we created carry a content hash; reuse on match.
// THE SIGNATURE FIRST, because everything below depends on it: the sanitiser
// needs to know the payload is an SVG, and the dedupe hash has to be taken on
// the bytes that will actually be written.
// #146 — THE BYTES DECIDE WHERE THEY CAN, AND NOWHERE ELSE DOES THE BUILD NARROW.
//
// A recognised signature names the file, so a payload declared `image/jpeg` whose
// bytes are a PNG is written `.png`. A payload PROVABLY prose is refused, which is
// the defect this exists for. Anything else — an encoder none of us has heard of,
// a BigTIFF, an SVG behind five thousand spaces — falls through to exactly what
// the deployed build does. Three review cycles were spent finding formats a
// whitelist refused; this shape cannot lose one.
$ext = self::image_extension_from_signature($bytes);
if ($ext === '') {
if (self::looks_like_text_not_image($bytes)) {
$notimage++;
continue; // prose, a stray URL — never an image, whatever the mime said
}
// Unrecognised but binary: keep 1.3.35's behaviour rather than guess.
$ext = isset($ext_map[$mime]) ? $ext_map[$mime] : preg_replace('/[^a-z0-9]/', '', $mime);
if ($ext === '') {
$notimage++;
continue;
}
}
// A RECOGNISED SIGNATURE CAN STILL NAME A TYPE THIS HOST WILL NOT TAKE, and then
// insisting on it is a narrowing of its own: review found `