PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 All 129 releases
wdesignkit / includes / abilities / widgets / wdesignkit-download-plugin.php

wdesignkit-download-plugin.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/abilities/widgets/wdesignkit-download-plugin.php

485 lines 18.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Ability: Package a local widget as a standalone, installable WordPress plugin ZIP.
4 *
5 * Mirrors the WP-admin "Download WP Plugin" popup: it posts the widget's PHP/JS/CSS to the
6 * cloud packager (api/v2/plugin/download/get), which wraps them in a plugin skeleton and
7 * returns a download URL.
8 *
9 * The one thing the popup does that a raw file upload must not skip: it re-emits the widget's
10 * code with WDKIT_DLPFX_ placeholders in front of every class / top-level function / widget
11 * name, which the packager swaps for a per-plugin prefix. Without them the downloaded plugin
12 * declares the SAME class names WDesignKit already registers, and installing it next to
13 * WDesignKit fatals with "Cannot declare class ...". This ability reproduces that step by
14 * parsing the stored files and prefixing the identifiers that are actually in them, which
15 * also keeps hand-written php_code (from wdesignkit/create-widget) intact.
16 */
17
18 declare(strict_types=1);
19
20 if (!defined('ABSPATH')) {
21 exit();
22 }
23
24 wp_register_ability('wdesignkit/download-plugin', [
25 'label' => __('Download Widget as WordPress Plugin', 'wdesignkit'),
26 'description' => __(
27 'Packages a local WDesignKit widget into a standalone, installable WordPress plugin ZIP and returns the download URL. Equivalent to the WP-admin "Download WP Plugin" popup. Requires cloud login.',
28 'wdesignkit',
29 ),
30 'category' => 'wdesignkit',
31 'input_schema' => [
32 'type' => 'object',
33 'properties' => [
34 'builder' => [
35 'type' => 'string',
36 'description' => 'Builder the widget belongs to.',
37 'enum' => ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'],
38 ],
39 'folder' => [
40 'type' => 'string',
41 'description' => 'Widget folder name inside the builder directory (the "folder" field from wdesignkit/list-widgets).',
42 ],
43 'plugin_name' => [
44 'type' => 'string',
45 'description' => 'Plugin display name shown in the WordPress plugins list. Required.',
46 'maxLength' => 100,
47 ],
48 'plugin_slug' => [
49 'type' => 'string',
50 'description' => 'Plugin folder/file slug. Lowercase letters, numbers, and hyphens; must start with a letter. Derived from plugin_name when omitted.',
51 'maxLength' => 100,
52 ],
53 'plugin_prefix' => [
54 'type' => 'string',
55 'description' => 'Short identifier prefix for the generated class, function, and constant names (e.g. "acme"). Derived from plugin_slug when omitted.',
56 'maxLength' => 50,
57 ],
58 'version' => [
59 'type' => 'string',
60 'description' => 'Plugin version for the plugin header. Defaults to 1.0.0.',
61 'maxLength' => 20,
62 ],
63 'author_name' => [
64 'type' => 'string',
65 'description' => 'Plugin author name. Left to the caller — the cloud falls back to "POSIMYTH" only when this is omitted.',
66 'maxLength' => 50,
67 ],
68 'author_url' => [
69 'type' => 'string',
70 'description' => 'Plugin author URL.',
71 ],
72 'licence_url' => [
73 'type' => 'string',
74 'description' => 'URL of the plugin licence.',
75 ],
76 'required_php_version' => [
77 'type' => 'string',
78 'description' => 'Minimum PHP version for the plugin header. Defaults to 8.0.',
79 ],
80 'required_wp_version' => [
81 'type' => 'string',
82 'description' => 'Minimum WordPress version for the plugin header. Defaults to 6.0.',
83 ],
84 'contributors' => [
85 'type' => 'string',
86 'description' => 'readme.txt contributors line.',
87 ],
88 'short_description' => [
89 'type' => 'string',
90 'description' => 'Short description for the plugin header and readme.txt.',
91 ],
92 'tags' => [
93 'type' => 'string',
94 'description' => 'Comma-separated readme.txt tags.',
95 ],
96 'text_domain' => [
97 'type' => 'string',
98 'description' => 'Translation text domain. Defaults to the plugin slug.',
99 ],
100 'faq' => [
101 'type' => 'string',
102 'description' => 'readme.txt FAQ section content.',
103 ],
104 'changelog' => [
105 'type' => 'string',
106 'description' => 'readme.txt changelog section content.',
107 ],
108 ],
109 'required' => ['builder', 'folder', 'plugin_name'],
110 'additionalProperties' => false,
111 ],
112 'output_schema' => [
113 'type' => 'object',
114 'properties' => [
115 'success' => ['type' => 'boolean'],
116 'message' => ['type' => 'string'],
117 'download_url' => ['type' => 'string'],
118 'zip_file_name' => ['type' => 'string'],
119 'plugin_slug' => ['type' => 'string'],
120 'response' => ['type' => ['object', 'array']],
121 ],
122 ],
123 'execute_callback' => 'wdesignkit_mcp_download_plugin',
124 'permission_callback' => 'wdesignkit_mcp_permission_callback',
125 'meta' => [
126 'public' => true,
127 'show_in_rest' => true,
128 'mcp' => ['public' => true],
129 'annotations' => [
130 'instructions' => implode("\n", [
131 'Packages one local widget as a standalone WordPress plugin ZIP. Requires cloud login.',
132 'Get builder + folder from wdesignkit/list-widgets.',
133 'plugin_name is required. plugin_slug must match ^[a-z][a-z0-9-]*$ — it is derived from plugin_name when omitted.',
134 'Returns download_url: a temporary link to the ZIP, valid for about an hour.',
135 'The generated plugin gets per-plugin class/function prefixes, so it can be installed on a site',
136 'that already runs WDesignKit with the same widget without a class-redeclaration fatal.',
137 ]),
138 'readonly' => false,
139 'destructive' => false,
140 'idempotent' => false,
141 ],
142 ],
143 ]);
144
145 /**
146 * Collect the class and top-level function names declared in a PHP file.
147 *
148 * Uses the tokenizer rather than regex so class methods (which must keep their names) are
149 * never confused with top-level functions, and so names inside strings or comments are not
150 * mistaken for declarations.
151 *
152 * @param string $php PHP source.
153 * @return string[] Declared identifiers, in declaration order.
154 */
155 function wdesignkit_mcp_dl_declared_identifiers(string $php): array {
156 if (!function_exists('token_get_all')) {
157 return [];
158 }
159
160 $tokens = @token_get_all($php);
161 if (!is_array($tokens)) {
162 return [];
163 }
164
165 $names = [];
166 $depth = 0;
167 $class_depths = [];
168 $expect = '';
169
170 foreach ($tokens as $token) {
171 if (is_string($token)) {
172 if ('{' === $token) {
173 $depth++;
174 } elseif ('}' === $token) {
175 $depth--;
176 // Leaving a class body — stop treating functions as methods.
177 while (!empty($class_depths) && end($class_depths) > $depth) {
178 array_pop($class_depths);
179 }
180 }
181 $expect = '';
182 continue;
183 }
184
185 [$id, $text] = $token;
186
187 if (T_WHITESPACE === $id || T_COMMENT === $id || T_DOC_COMMENT === $id) {
188 continue;
189 }
190
191 if (T_CLASS === $id) {
192 $expect = 'class';
193 continue;
194 }
195
196 if (T_FUNCTION === $id) {
197 $expect = 'function';
198 continue;
199 }
200
201 if (T_STRING === $id && '' !== $expect) {
202 if ('class' === $expect) {
203 $names[] = $text;
204 $class_depths[] = $depth + 1;
205 } elseif (empty($class_depths)) {
206 // Top-level function only; methods keep their names.
207 $names[] = $text;
208 }
209 }
210
211 $expect = '';
212 }
213
214 return array_values(array_unique($names));
215 }
216
217 /**
218 * Insert the WDKIT_DLPFX_ placeholder in front of every identifier the packager must rename.
219 *
220 * @param string $php Stored widget PHP.
221 * @param string $builder Builder slug.
222 * @return string PHP with placeholders, ready for the cloud packager.
223 */
224 function wdesignkit_mcp_dl_prefix_php(string $php, string $builder): string {
225 if ('' === trim($php)) {
226 return $php;
227 }
228
229 // Asset URLs: the generators emit an uploads path on THIS site
230 // ($baseurl . '/wdesignkit/<builder>/<folder>/<file>.css'), which does not exist on
231 // whatever site the downloaded plugin is installed on. Hand the packager the
232 // WDKIT_SERVER_PATH token it already knows how to repoint at the packaged assets.
233 $php = preg_replace('#\$baseurl\s*\.\s*\'/wdesignkit/#', "WDKIT_SERVER_PATH . '/", $php);
234
235 foreach (wdesignkit_mcp_dl_declared_identifiers($php) as $name) {
236 $php = preg_replace(
237 '/\b' . preg_quote($name, '/') . '\b/',
238 'WDKIT_DLPFX_' . $name,
239 $php
240 );
241 }
242
243 // Elementor/Bricks widget name string ('wb-<hash>'). The quote immediately before "wb-"
244 // keeps this away from the Gutenberg block name 'wdkit/wb-<hash>', which the packager
245 // deliberately leaves alone and guards at registration instead.
246 if (in_array($builder, ['elementor', 'bricks'], true)) {
247 $php = preg_replace('/([\'"])wb-/', '${1}WDKIT_DLPFX_wb-', $php);
248 }
249
250 // Elementor widget classes are registered by the file itself in a downloaded plugin —
251 // the generated plugin's main class only require_once's this file.
252 if ('elementor' === $builder
253 && false === strpos($php, 'widgets_manager->register')
254 && preg_match('/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\s+extends\s+\\\\?Widget_Base\b/', $php, $m)
255 ) {
256 $php = rtrim($php) . "\n\n\\Elementor\\Plugin::instance()->widgets_manager->register( new {$m[1]}() );\n";
257 }
258
259 return $php;
260 }
261
262 /**
263 * Prefix the classes declared in a widget's editor JS (Gutenberg builds wrap the block in one).
264 *
265 * @param string $js Stored widget JS.
266 * @return string
267 */
268 function wdesignkit_mcp_dl_prefix_js(string $js): string {
269 if ('' === trim($js)) {
270 return $js;
271 }
272
273 if (!preg_match_all('/\bclass\s+([A-Za-z_$][A-Za-z0-9_$]*)/', $js, $matches)) {
274 return $js;
275 }
276
277 foreach (array_unique($matches[1]) as $name) {
278 $js = preg_replace(
279 '/\b' . preg_quote($name, '/') . '\b/',
280 'WDKIT_DLPFX_' . $name,
281 $js
282 );
283 }
284
285 return $js;
286 }
287
288 function wdesignkit_mcp_download_plugin(array $input): array {
289 // Timeout guard: the cloud builds and zips the plugin during this request.
290 set_time_limit(120);
291
292 if (!defined('WDKIT_BUILDER_PATH') || !defined('WDKIT_SERVER_API_URL')) {
293 return ['success' => false, 'message' => 'WDesignKit plugin is not active.'];
294 }
295
296 $auth = wdesignkit_mcp_template_get_auth();
297 if (empty($auth['logged_in'])) {
298 return ['success' => false, 'message' => $auth['message'] ?? 'Not logged in to WDesignKit cloud.'];
299 }
300
301 $builder = sanitize_text_field((string) ($input['builder'] ?? ''));
302 $folder = sanitize_file_name((string) ($input['folder'] ?? ''));
303
304 if (!in_array($builder, ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'], true)) {
305 return ['success' => false, 'message' => 'Invalid builder type.'];
306 }
307
308 $widget_dir = WDKIT_BUILDER_PATH . '/' . $builder . '/' . $folder;
309
310 if ('' === $folder || !is_dir($widget_dir)) {
311 return ['success' => false, 'message' => "Widget folder not found: {$builder}/{$folder}"];
312 }
313
314 $real_widget = realpath($widget_dir);
315 $real_base = realpath(WDKIT_BUILDER_PATH);
316 if (!$real_widget || !$real_base || strpos($real_widget, $real_base . DIRECTORY_SEPARATOR) !== 0) {
317 return ['success' => false, 'message' => 'Invalid widget path.'];
318 }
319
320 // --- Plugin metadata ------------------------------------------------------------
321 $plugin_name = trim(sanitize_text_field((string) ($input['plugin_name'] ?? '')));
322
323 if ('' === $plugin_name) {
324 return ['success' => false, 'message' => 'plugin_name is required.'];
325 }
326
327 $plugin_slug = strtolower(trim((string) ($input['plugin_slug'] ?? '')));
328 if ('' === $plugin_slug) {
329 $plugin_slug = sanitize_title($plugin_name);
330 }
331
332 // Mirror the cloud's own slug rule so a bad slug fails here with a usable message
333 // instead of coming back as a generic 400 from the packager.
334 if (!preg_match('/^[a-z][a-z0-9-]{0,99}$/', $plugin_slug)) {
335 return [
336 'success' => false,
337 'message' => "Invalid plugin_slug '{$plugin_slug}'. Use lowercase letters, numbers, and hyphens only, starting with a letter.",
338 ];
339 }
340
341 $plugin_prefix = strtolower(trim((string) ($input['plugin_prefix'] ?? '')));
342 if ('' === $plugin_prefix) {
343 // First slug segment keeps generated identifiers short and readable.
344 $plugin_prefix = explode('-', $plugin_slug)[0];
345 }
346
347 if (!preg_match('/^[a-z][a-z0-9_-]{0,49}$/', $plugin_prefix)) {
348 return [
349 'success' => false,
350 'message' => "Invalid plugin_prefix '{$plugin_prefix}'. Use lowercase letters, numbers, hyphens, and underscores only, starting with a letter.",
351 ];
352 }
353
354 // --- Widget files ---------------------------------------------------------------
355 $files = array_diff(@scandir($widget_dir) ?: [], ['.', '..']);
356 $json_data = null;
357 $php_file = '';
358 $css_file = '';
359 $js_file = '';
360 $react_file = '';
361
362 foreach ($files as $f) {
363 $path = $widget_dir . '/' . $f;
364 $ext = pathinfo($f, PATHINFO_EXTENSION);
365
366 if ('index.js' === $f) {
367 // Gutenberg frontend loader — packaged separately from the editor JS.
368 $react_file = (string) @file_get_contents($path);
369 continue;
370 }
371
372 if ('json' === $ext && null === $json_data) {
373 $raw = @file_get_contents($path);
374 $json_data = (false !== $raw) ? json_decode($raw, true) : null;
375 } elseif ('php' === $ext && '' === $php_file) {
376 $php_file = (string) @file_get_contents($path);
377 } elseif ('css' === $ext && '' === $css_file) {
378 $css_file = (string) @file_get_contents($path);
379 } elseif ('js' === $ext && '' === $js_file) {
380 $js_file = (string) @file_get_contents($path);
381 }
382 }
383
384 if ('' === trim($php_file)) {
385 return ['success' => false, 'message' => "No PHP file found in {$builder}/{$folder}. The widget cannot be packaged without it."];
386 }
387
388 $widgetdata = $json_data['widget_data']['widgetdata'] ?? [];
389 if (empty($widgetdata) || !is_array($widgetdata)) {
390 return ['success' => false, 'message' => "Widget JSON config missing or unreadable in {$builder}/{$folder}."];
391 }
392
393 // The packager reads widgetdata.type to decide which plugin skeleton to build.
394 $widgetdata['type'] = $builder;
395
396 // --- Prefix identifiers so the ZIP can coexist with WDesignKit --------------------
397 $php_file = wdesignkit_mcp_dl_prefix_php($php_file, $builder);
398 $js_file = wdesignkit_mcp_dl_prefix_js($js_file);
399
400 if ('' !== $react_file) {
401 $react_file = wdesignkit_mcp_dl_prefix_js($react_file);
402 }
403
404 $args = [
405 'token' => $auth['token'],
406 'pluginName' => $plugin_name,
407 'pluginSlug' => $plugin_slug,
408 'pluginPrefix' => $plugin_prefix,
409 // version is honoured by the cloud packager's plugin header.
410 'version' => sanitize_text_field((string) ($input['version'] ?? '1.0.0')),
411 'authorName' => sanitize_text_field((string) ($input['author_name'] ?? '')),
412 'authorUrl' => esc_url_raw((string) ($input['author_url'] ?? '')),
413 'licenceUrl' => esc_url_raw((string) ($input['licence_url'] ?? '')),
414 'requiredPhpVersion' => sanitize_text_field((string) ($input['required_php_version'] ?? '')),
415 'requiredWpVersion' => sanitize_text_field((string) ($input['required_wp_version'] ?? '')),
416 'contributors' => sanitize_text_field((string) ($input['contributors'] ?? '')),
417 'shortDescription' => sanitize_text_field((string) ($input['short_description'] ?? '')),
418 'tags' => sanitize_text_field((string) ($input['tags'] ?? '')),
419 'textDomain' => sanitize_text_field((string) ($input['text_domain'] ?? '')),
420 'faq' => wp_kses_post((string) ($input['faq'] ?? '')),
421 'changelog' => wp_kses_post((string) ($input['changelog'] ?? '')),
422 'php_file' => $php_file,
423 'js_file' => $js_file,
424 'css_file' => $css_file,
425 'widgetdata' => wp_json_encode($widgetdata),
426 'unique_id' => get_option('wdkit_unique_id', ''),
427 ];
428
429 if ('' !== $react_file) {
430 $args['js_react_file'] = $react_file;
431 }
432
433 $response = wp_remote_post(
434 WDKIT_SERVER_API_URL . 'api/v2/plugin/download/get',
435 [
436 'method' => 'POST',
437 'body' => $args,
438 'timeout' => 90,
439 ]
440 );
441
442 if (is_wp_error($response)) {
443 return ['success' => false, 'message' => $response->get_error_message()];
444 }
445
446 $status = wp_remote_retrieve_response_code($response);
447 $body = wp_remote_retrieve_body($response);
448 $data = json_decode($body, true);
449
450 if (!is_array($data)) {
451 return [
452 'success' => false,
453 'message' => "Cloud returned status {$status} with a non-JSON body while building the plugin ZIP.",
454 ];
455 }
456
457 if (200 !== (int) $status || empty($data['success'])) {
458 return [
459 'success' => false,
460 'message' => $data['message'] ?? $data['massage'] ?? "Cloud returned status {$status}.",
461 'response' => $data,
462 ];
463 }
464
465 $download_url = $data['data']['download_url'] ?? '';
466 $zip_name = $data['data']['zip_file_name'] ?? '';
467
468 if ('' === $download_url) {
469 return [
470 'success' => false,
471 'message' => 'The cloud reported success but returned no download URL.',
472 'response' => $data,
473 ];
474 }
475
476 return [
477 'success' => true,
478 'message' => "Plugin '{$plugin_name}' packaged from {$builder}/{$folder}. Download it from download_url (the link expires after about an hour).",
479 'download_url' => $download_url,
480 'zip_file_name' => $zip_name,
481 'plugin_slug' => $plugin_slug,
482 'response' => $data,
483 ];
484 }
485