PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.11
ZIP AI – AI Website Builder & AI Agent v0.0.11
0.0.14 0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / imports / sureforms-class-bridge.php

sureforms-class-bridge.php in ZIP AI – AI Website Builder & AI Agent 0.0.11, at inc/imports/sureforms-class-bridge.php

329 lines 10.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SureForms class bridge — the built site's forms wear the site's own classes.
4 *
5 * Why this exists
6 * ----------------
7 * A built page's `<form>` becomes a SureForms form so submissions work. SureForms
8 * owns that form's markup and behaviour; the page owns its look. The two meet
9 * here: the converter recorded which classes every part of the source form wore
10 * (`form`, `submit`, and per field `row` / `label` / `control` / `help` /
11 * `choices` / `choice` / `option` — the library class plus the `gs-*` class the
12 * GBS store holds the rule under), the committer stored that map on the form
13 * post as `_zipai_form_classes`, and this bridge ADDS those classes to the
14 * elements SureForms renders. The page's own rules then lay the form out and
15 * paint it — the same rules that styled the preview the user approved. Nothing
16 * is removed or restructured: `WP_HTML_Tag_Processor::add_class` only.
17 *
18 * SureForms' own skin is switched off for every form we own through its
19 * `srfm_disable_default_styles` filter (2.12.2+): a form carrying a
20 * `_zipai_form_hash` is ours. No styling meta is written; the switch is a rule.
21 *
22 * Target map (SureForms 2.12.x anatomy, `inc/generate-form-markup.php`,
23 * `inc/fields/*-markup.php`):
24 * form → `form.srfm-form`
25 * submit → `button#srfm-submit-btn`
26 * fields[i].row → the i-th `.srfm-block-single`
27 * .label → its `.srfm-block-label` / `.srfm-block-legend`
28 * .control → its `input|textarea.srfm-input-common`, `select.srfm-dropdown-common`
29 * (TomSelect copies the select's classes onto `.ts-wrapper`);
30 * NEVER a checkbox/radio input — SureForms hides those behind
31 * a drawn box, and a text-control class on the proxy paints it.
32 * .help → its `.srfm-description`
33 * .choices → a choice group's `.srfm-block-wrap` (the option grid)
34 * .choice → each `.srfm-multi-choice-single` (the pill)
35 * .option → each `input.srfm-input-multi-choice-single`
36 * A field count that no longer matches the render (the form was edited in the
37 * SureForms editor) dresses the form and the submit only.
38 *
39 * @package zip-ai
40 */
41
42 namespace ZipAI\MCP\Classes\Imports;
43
44 use WP_HTML_Tag_Processor;
45
46 defined( 'ABSPATH' ) || exit;
47
48 /**
49 * Adds the source classes to SureForms' rendered elements and switches the
50 * SureForms skin off for every form the build owns.
51 *
52 * @since 0.0.6
53 */
54 class SureformsClassBridge {
55 /**
56 * The form post meta holding the class map (a JSON string — see
57 * `wp-importer` `formClassesMeta`).
58 *
59 * @var string
60 */
61 public const CLASSES_META_KEY = '_zipai_form_classes';
62
63 /**
64 * The provenance meta every form the build creates carries (written by
65 * `wp-importer`, registered by spectra-blocks). Its presence is ownership.
66 *
67 * @var string
68 */
69 public const HASH_META_KEY = '_zipai_form_hash';
70
71 /**
72 * A class token the bridge will add: anything without whitespace or a quote
73 * or angle bracket. `sanitize_html_class` is too narrow — a Tailwind token
74 * carries `:` / `/` / `[`.
75 *
76 * @var string
77 */
78 private const TOKEN_RE = '/^[^\s"\'<>]+$/';
79
80 /**
81 * Register the meta and the hooks.
82 */
83 public function __construct() {
84 add_action( 'init', array( $this, 'register_meta' ) );
85 add_filter( 'srfm_disable_default_styles', array( $this, 'disable_skin_for_owned_form' ), 10, 2 );
86 add_filter( 'do_shortcode_tag', array( $this, 'dress_shortcode' ), 10, 3 );
87 add_filter( 'render_block_srfm/form', array( $this, 'dress_block' ), 10, 2 );
88 }
89
90 /**
91 * `_zipai_form_classes` on the SureForms form post — a string, REST-writable
92 * by anyone who may edit the form (the committer writes it through
93 * `/wp/v2/sureforms_form/{id}`).
94 *
95 * @return void
96 */
97 public function register_meta(): void {
98 register_post_meta(
99 'sureforms_form',
100 self::CLASSES_META_KEY,
101 array(
102 'type' => 'string',
103 'single' => true,
104 'show_in_rest' => true,
105 'auth_callback' => static function () {
106 return current_user_can( 'edit_posts' );
107 },
108 'sanitize_callback' => static function ( $value ) {
109 return is_string( $value ) ? substr( $value, 0, 65535 ) : '';
110 },
111 )
112 );
113 }
114
115 /**
116 * `srfm_disable_default_styles`: a form the build owns renders without
117 * SureForms' stylesheets and inline variables.
118 *
119 * @param bool $disabled The stored per-form flag.
120 * @param int $form_id Form post ID.
121 * @return bool
122 */
123 public function disable_skin_for_owned_form( $disabled, $form_id ): bool {
124 return (bool) $disabled || $this->owns( (int) $form_id );
125 }
126
127 /**
128 * `do_shortcode_tag`: the `[sureforms id="N"]` embed the committer writes.
129 *
130 * @param string $output Rendered form.
131 * @param string $tag Shortcode tag.
132 * @param array<string,mixed>|string $attr Shortcode attributes.
133 * @return string
134 */
135 public function dress_shortcode( $output, $tag, $attr ) {
136 if ( 'sureforms' !== $tag || ! is_array( $attr ) ) {
137 return $output;
138 }
139 return $this->dress( (string) $output, self::int_of( $attr['id'] ?? 0 ) );
140 }
141
142 /**
143 * `render_block_srfm/form`: the block embed.
144 *
145 * @param string $content Rendered form.
146 * @param array<string, mixed> $block Parsed block.
147 * @return string
148 */
149 public function dress_block( $content, $block ) {
150 $attrs = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
151 return $this->dress( (string) $content, self::int_of( $attrs['id'] ?? 0 ) );
152 }
153
154 /**
155 * An id from a shortcode / block attribute.
156 *
157 * @param mixed $v Attribute value.
158 * @return int
159 */
160 private static function int_of( $v ): int {
161 return is_numeric( $v ) ? (int) $v : 0;
162 }
163
164 /**
165 * Add the stored classes to the rendered form's elements. Additions only;
166 * a form without a map renders untouched.
167 *
168 * @param string $html Rendered form HTML.
169 * @param int $form_id Form post ID.
170 * @return string
171 */
172 public function dress( string $html, int $form_id ): string {
173 $map = $this->class_map( $form_id );
174 if ( null === $map || '' === $html ) {
175 return $html;
176 }
177 // Rows are matched by position, so a render whose field count no longer
178 // matches the map (the form was edited in the SureForms editor) keeps its
179 // rows as rendered — only the form and the submit are dressed, and the
180 // form is marked `data-zipai-classes="stale"` so support can see it.
181 $stale = $this->row_count( $html ) !== count( $map['fields'] );
182 $fields = $stale ? array() : $map['fields'];
183 $p = new WP_HTML_Tag_Processor( $html );
184 $row = -1;
185 $field = null;
186 while ( $p->next_tag() ) {
187 $tag = $p->get_tag();
188 if ( 'FORM' === $tag && $p->has_class( 'srfm-form' ) ) {
189 $this->add( $p, $map['form'] );
190 if ( $stale ) {
191 $p->set_attribute( 'data-zipai-classes', 'stale' );
192 }
193 continue;
194 }
195 if ( 'BUTTON' === $tag && 'srfm-submit-btn' === $p->get_attribute( 'id' ) ) {
196 $this->add( $p, $map['submit'] );
197 continue;
198 }
199 if ( $p->has_class( 'srfm-block-single' ) ) {
200 ++$row;
201 $field = $fields[ $row ] ?? null;
202 if ( null !== $field ) {
203 $this->add( $p, $field['row'] );
204 }
205 continue;
206 }
207 if ( null === $field ) {
208 continue;
209 }
210 if ( $p->has_class( 'srfm-block-label' ) || $p->has_class( 'srfm-block-legend' ) ) {
211 $this->add( $p, $field['label'] );
212 } elseif ( $this->is_text_control( $p, $tag ) ) {
213 $this->add( $p, $field['control'] );
214 } elseif ( $p->has_class( 'srfm-description' ) ) {
215 $this->add( $p, $field['help'] );
216 } elseif ( $p->has_class( 'srfm-block-wrap' ) && '' !== $field['choices'] ) {
217 $this->add( $p, $field['choices'] );
218 } elseif ( $p->has_class( 'srfm-multi-choice-single' ) ) {
219 $this->add( $p, $field['choice'] );
220 } elseif ( $p->has_class( 'srfm-input-multi-choice-single' ) ) {
221 $this->add( $p, $field['option'] );
222 }
223 }
224 return $p->get_updated_html();
225 }
226
227 /**
228 * How many field rows the render holds.
229 *
230 * @param string $html Rendered form HTML.
231 * @return int
232 */
233 private function row_count( string $html ): int {
234 $p = new WP_HTML_Tag_Processor( $html );
235 $n = 0;
236 while ( $p->next_tag() ) {
237 if ( $p->has_class( 'srfm-block-single' ) ) {
238 ++$n;
239 }
240 }
241 return $n;
242 }
243
244 /**
245 * A visible text-like control: input / textarea / select, never a
246 * checkbox, radio or hidden input.
247 *
248 * @param WP_HTML_Tag_Processor $p Processor at the tag.
249 * @param string|null $tag Tag name.
250 * @return bool
251 */
252 private function is_text_control( WP_HTML_Tag_Processor $p, ?string $tag ): bool {
253 if ( ! in_array( $tag, array( 'INPUT', 'TEXTAREA', 'SELECT' ), true ) ) {
254 return false;
255 }
256 if ( ! $p->has_class( 'srfm-input-common' ) && ! $p->has_class( 'srfm-dropdown-common' ) ) {
257 return false;
258 }
259 $type = strtolower( (string) $p->get_attribute( 'type' ) );
260 return ! in_array( $type, array( 'checkbox', 'radio', 'hidden' ), true );
261 }
262
263 /**
264 * Add each token of a class string.
265 *
266 * @param WP_HTML_Tag_Processor $p Processor at the tag.
267 * @param string $classes Space-separated tokens.
268 * @return void
269 */
270 private function add( WP_HTML_Tag_Processor $p, string $classes ): void {
271 $tokens = preg_split( '/\s+/', trim( $classes ) );
272 if ( ! is_array( $tokens ) ) {
273 return;
274 }
275 foreach ( $tokens as $token ) {
276 if ( '' !== $token && 1 === preg_match( self::TOKEN_RE, $token ) ) {
277 $p->add_class( $token );
278 }
279 }
280 }
281
282 /**
283 * The stored class map, normalised: every key a string, every field every
284 * part. Null when the form carries none or the JSON is not a map.
285 *
286 * @param int $form_id Form post ID.
287 * @return array{form:string,submit:string,fields:array<int,array<string,string>>}|null
288 */
289 private function class_map( int $form_id ): ?array {
290 if ( $form_id <= 0 ) {
291 return null;
292 }
293 $raw = get_post_meta( $form_id, self::CLASSES_META_KEY, true );
294 if ( ! is_string( $raw ) || '' === $raw ) {
295 return null;
296 }
297 $data = json_decode( $raw, true );
298 if ( ! is_array( $data ) ) {
299 return null;
300 }
301 $str = static fn( $v ): string => is_string( $v ) ? $v : '';
302 $parts = array( 'row', 'label', 'control', 'help', 'choices', 'choice', 'option' );
303 $fields = array();
304 foreach ( is_array( $data['fields'] ?? null ) ? $data['fields'] : array() as $f ) {
305 $field = array();
306 foreach ( $parts as $part ) {
307 $field[ $part ] = is_array( $f ) ? $str( $f[ $part ] ?? '' ) : '';
308 }
309 $fields[] = $field;
310 }
311 return array(
312 'form' => $str( $data['form'] ?? '' ),
313 'submit' => $str( $data['submit'] ?? '' ),
314 'fields' => $fields,
315 );
316 }
317
318 /**
319 * The build created this form.
320 *
321 * @param int $form_id Form post ID.
322 * @return bool
323 */
324 private function owns( int $form_id ): bool {
325 $hash = $form_id > 0 ? get_post_meta( $form_id, self::HASH_META_KEY, true ) : '';
326 return is_string( $hash ) && '' !== $hash;
327 }
328 }
329