66
77class GF_Zero_Spam {
88
9+ /**
10+ * Scripts queued for output after each form.
11+ *
12+ * @since TBD
13+ *
14+ * @var array<int, string> Keyed by form ID.
15+ */
16+ private $ pending_scripts = [];
17+
918 /**
1019 * Instantiates the plugin on Gravity Forms loading.
1120 *
@@ -47,6 +56,7 @@ public function __construct() {
4756 new GF_Zero_Spam_Token_Endpoint ();
4857
4958 add_action ( 'gform_register_init_scripts ' , [ $ this , 'add_key_field ' ], 1 );
59+ add_filter ( 'gform_get_form_filter ' , [ $ this , 'enqueue_script ' ], 10 , 2 );
5060 add_filter ( 'gform_entry_is_spam ' , [ $ this , 'check_key_field ' ], 10 , 3 );
5161 add_filter ( 'gform_incomplete_submission_pre_save ' , [ $ this , 'add_zero_spam_key_to_entry ' ], 10 , 3 );
5262 add_filter ( 'gform_abort_submission_with_confirmation ' , [ $ this , 'maybe_abort_submission ' ], 20 , 2 );
@@ -167,11 +177,13 @@ public function get_key() {
167177 }
168178
169179 /**
170- * Injects the hidden field and key into the form at submission .
180+ * Collects the Zero Spam configuration for a form.
171181 *
172- * @since 1.0
182+ * The configuration is passed to a separate JavaScript file via
183+ * wp_localize_script to avoid breaking Gravity Forms' conditional logic
184+ * if a JS optimization plugin mangles the inline script.
173185 *
174- * @uses GFFormDisplay::add_init_script() to inject the code into the `gform_post_render` hook.
186+ * @since 1.0
175187 *
176188 * @param array $form The Form Object.
177189 *
@@ -200,10 +212,6 @@ public function add_key_field( $form ) {
200212
201213 $ form_id = (int ) $ form ['id ' ];
202214
203- $ fallback_token = GF_Zero_Spam_Token::mint ( $ form_id , DAY_IN_SECONDS );
204- $ rest_url = esc_url ( rest_url ( 'gf-zero-spam/v1/token ' ) );
205- $ ajax_url = esc_url ( admin_url ( 'admin-ajax.php ' ) );
206-
207215 /**
208216 * Filters the timeout (in milliseconds) for AJAX token fetch attempts.
209217 *
@@ -213,131 +221,57 @@ public function add_key_field( $form ) {
213221 */
214222 $ timeout = (int ) apply_filters ( 'gf_zero_spam_token_fetch_timeout ' , 3000 );
215223
216- // Embedded as a JS object literal since add_init_script doesn't use a registered script handle.
217- $ config = wp_json_encode (
218- [
219- 'restUrl ' => $ rest_url ,
220- 'ajaxUrl ' => $ ajax_url ,
221- 'fallbackToken ' => $ fallback_token ,
222- 'formId ' => $ form_id ,
223- 'timeout ' => $ timeout ,
224- ]
225- );
226-
227- if ( version_compare ( GFForms::$ version , '2.9.0 ' , '>= ' ) ) {
228- $ script = <<<EOD
229- gform.utils.addAsyncFilter('gform/submission/pre_submission', async (data) => {
230- const cfg = {$ config };
231-
232- // Only process the form this filter was registered for.
233- if (parseInt(data.form.dataset.formid, 10) !== cfg.formId) {
234- return data;
235- }
236-
237- let token = cfg.fallbackToken;
238-
239- try {
240- const ctrl = new AbortController();
241- const timer = setTimeout(() => ctrl.abort(), cfg.timeout);
242- const res = await fetch(cfg.restUrl + '?form_id=' + cfg.formId, { signal: ctrl.signal });
243-
244- clearTimeout(timer);
245-
246- if (res.ok) {
247- const json = await res.json();
248- token = json.token;
249- } else {
250- throw new Error('REST failed');
251- }
252- } catch (e) {
253- try {
254- const ctrl2 = new AbortController();
255- const timer2 = setTimeout(() => ctrl2.abort(), cfg.timeout);
256- const res2 = await fetch(cfg.ajaxUrl + '?action=gf_zero_spam_token&form_id=' + cfg.formId, { signal: ctrl2.signal });
257-
258- clearTimeout(timer2);
259-
260- if (res2.ok) {
261- const json2 = await res2.json();
262- token = json2.token;
263- }
264- } catch (e2) {
265- // Both endpoints failed; use fallback token.
266- }
267- }
268-
269- const old = data.form.querySelector('input[name="gf_zero_spam_token"]');
270- if (old) { old.remove(); }
271-
272- const input = document.createElement('input');
273- input.type = 'hidden';
274- input.name = 'gf_zero_spam_token';
275- input.value = token;
276- input.setAttribute('autocomplete', 'new-password');
277- data.form.appendChild(input);
278-
279- return data;
280- });
281- EOD ;
282- } else {
283- $ script = <<<EOD
284- const gfzsForm = document.getElementById('gform_ {$ form_id }');
285-
286- if (gfzsForm && !gfzsForm.dataset.gfzsBound) {
287- gfzsForm.dataset.gfzsBound = '1';
288- gfzsForm.addEventListener('submit', async function(e) {
289- e.preventDefault();
290-
291- const cfg = {$ config };
292- let token = cfg.fallbackToken;
293-
294- try {
295- const ctrl = new AbortController();
296- const timer = setTimeout(() => ctrl.abort(), cfg.timeout);
297- const res = await fetch(cfg.restUrl + '?form_id=' + cfg.formId, { signal: ctrl.signal });
298-
299- clearTimeout(timer);
300-
301- if (res.ok) {
302- const json = await res.json();
303- token = json.token;
304- } else {
305- throw new Error('REST failed');
306- }
307- } catch (e1) {
308- try {
309- const ctrl2 = new AbortController();
310- const timer2 = setTimeout(() => ctrl2.abort(), cfg.timeout);
311- const res2 = await fetch(cfg.ajaxUrl + '?action=gf_zero_spam_token&form_id=' + cfg.formId, { signal: ctrl2.signal });
312-
313- clearTimeout(timer2);
314-
315- if (res2.ok) {
316- const json2 = await res2.json();
317- token = json2.token;
318- }
319- } catch (e2) {
320- // Both endpoints failed; use fallback token.
321- }
322- }
323-
324- const old = this.querySelector('input[name="gf_zero_spam_token"]');
325- if (old) { old.remove(); }
326-
327- const input = document.createElement('input');
328- input.type = 'hidden';
329- input.name = 'gf_zero_spam_token';
330- input.value = token;
331- input.setAttribute('autocomplete', 'new-password');
332- this.appendChild(input);
333-
334- this.submit();
335- });
336- }
337- EOD ;
338- }
339-
340- GFFormDisplay::add_init_script ( $ form_id , 'gf-zero-spam ' , GFFormDisplay::ON_PAGE_RENDER , $ script );
224+ $ this ->pending_scripts [ $ form_id ] = [
225+ 'restUrl ' => esc_url_raw ( rest_url ( 'gf-zero-spam/v1/token ' ) ),
226+ 'ajaxUrl ' => esc_url_raw ( admin_url ( 'admin-ajax.php ' ) ),
227+ 'fallbackToken ' => GF_Zero_Spam_Token::mint ( $ form_id , DAY_IN_SECONDS ),
228+ 'formId ' => $ form_id ,
229+ 'timeout ' => $ timeout ,
230+ ];
231+ }
232+
233+ /**
234+ * Enqueues the Zero Spam script with collected form configurations.
235+ *
236+ * Uses a separate JavaScript file loaded after Gravity Forms' scripts so
237+ * that any error does not prevent conditional logic from executing, which
238+ * would leave the form hidden with display:none.
239+ *
240+ * @since TBD
241+ *
242+ * @param string $form_string The form HTML.
243+ * @param array $form The Form Object.
244+ *
245+ * @return string The unmodified form HTML.
246+ */
247+ public function enqueue_script ( $ form_string , $ form ) {
248+ if ( empty ( $ this ->pending_scripts ) ) {
249+ return $ form_string ;
250+ }
251+
252+ if ( wp_script_is ( 'gf-zero-spam ' , 'enqueued ' ) ) {
253+ return $ form_string ;
254+ }
255+
256+ $ handle = version_compare ( GFForms::$ version , '2.9.0 ' , '>= ' )
257+ ? 'gform_gravityforms_utils '
258+ : 'gform_gravityforms ' ;
259+
260+ wp_enqueue_script (
261+ 'gf-zero-spam ' ,
262+ plugins_url ( 'dist/js/gf-zero-spam.js ' , GF_ZERO_SPAM_FILE ),
263+ [ $ handle ],
264+ (string ) @filemtime ( GF_ZERO_SPAM_DIR . 'dist/js/gf-zero-spam.js ' ), // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Graceful fallback when file is missing.
265+ true
266+ );
267+
268+ wp_localize_script (
269+ 'gf-zero-spam ' ,
270+ 'gfZeroSpamConfig ' ,
271+ [ 'forms ' => array_values ( $ this ->pending_scripts ) ]
272+ );
273+
274+ return $ form_string ;
341275 }
342276
343277 /**
0 commit comments