@wp-typia/project-tools 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/runtime/scaffold-onboarding.js +3 -2
  2. package/dist/runtime/scaffold.d.ts +1 -0
  3. package/dist/runtime/scaffold.js +3 -0
  4. package/dist/runtime/schema-core.d.ts +1 -0
  5. package/dist/runtime/schema-core.js +188 -8
  6. package/package.json +1 -1
  7. package/templates/_shared/compound/persistence/scripts/block-config.ts.mustache +16 -0
  8. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/api-types.ts.mustache +10 -0
  9. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/api.ts.mustache +23 -0
  10. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/data.ts.mustache +45 -0
  11. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/interactivity.ts.mustache +201 -42
  12. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/render.php.mustache +37 -43
  13. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/types.ts.mustache +13 -8
  14. package/templates/_shared/compound/persistence-auth/{{slugKebabCase}}.php.mustache +139 -0
  15. package/templates/_shared/compound/persistence-public/{{slugKebabCase}}.php.mustache +159 -0
  16. package/templates/_shared/persistence/auth/{{slugKebabCase}}.php.mustache +139 -0
  17. package/templates/_shared/persistence/core/scripts/sync-rest-contracts.ts.mustache +16 -0
  18. package/templates/_shared/persistence/core/src/api-types.ts.mustache +10 -0
  19. package/templates/_shared/persistence/core/src/api.ts.mustache +23 -0
  20. package/templates/_shared/persistence/core/src/data.ts.mustache +45 -0
  21. package/templates/_shared/persistence/core/src/interactivity.ts.mustache +201 -44
  22. package/templates/_shared/persistence/public/{{slugKebabCase}}.php.mustache +159 -0
  23. package/templates/_shared/rest-helpers/public/inc/rest-public.php.mustache +1 -1
  24. package/templates/_shared/workspace/persistence-auth/server.php.mustache +139 -0
  25. package/templates/_shared/workspace/persistence-public/inc/rest-public.php.mustache +1 -1
  26. package/templates/_shared/workspace/persistence-public/server.php.mustache +159 -0
  27. package/templates/persistence/src/edit.tsx.mustache +1 -1
  28. package/templates/persistence/src/render.php.mustache +37 -43
  29. package/templates/persistence/src/types.ts.mustache +13 -9
@@ -1,17 +1,20 @@
1
1
  import { getContext, store } from '@wordpress/interactivity';
2
2
  import { generatePublicWriteRequestId } from '@wp-typia/block-runtime/identifiers';
3
3
 
4
- import { fetchState, writeState } from './api';
5
- import type { {{pascalCase}}Context, {{pascalCase}}State } from './types';
4
+ import { fetchBootstrap, fetchState, writeState } from './api';
5
+ import type {
6
+ {{pascalCase}}ClientState,
7
+ {{pascalCase}}Context,
8
+ {{pascalCase}}State,
9
+ } from './types';
6
10
 
7
11
  function hasExpiredPublicWriteToken(
8
- context: {{pascalCase}}Context
12
+ expiresAt?: number
9
13
  ): boolean {
10
14
  return (
11
- context.persistencePolicy === 'public' &&
12
- typeof context.publicWriteExpiresAt === 'number' &&
13
- context.publicWriteExpiresAt > 0 &&
14
- Date.now() >= context.publicWriteExpiresAt * 1000
15
+ typeof expiresAt === 'number' &&
16
+ expiresAt > 0 &&
17
+ Date.now() >= expiresAt * 1000
15
18
  );
16
19
  }
17
20
 
@@ -20,70 +23,212 @@ function getWriteBlockedMessage(
20
23
  ): string {
21
24
  return context.persistencePolicy === 'authenticated'
22
25
  ? 'Sign in to persist this counter.'
23
- : 'Reload the page to refresh this write token.';
26
+ : 'Public writes are temporarily unavailable.';
27
+ }
28
+
29
+ const BOOTSTRAP_MAX_ATTEMPTS = 3;
30
+ const BOOTSTRAP_RETRY_DELAYS_MS = [ 250, 500 ];
31
+
32
+ async function waitForBootstrapRetry( delayMs: number ): Promise< void > {
33
+ await new Promise( ( resolve ) => {
34
+ setTimeout( resolve, delayMs );
35
+ } );
36
+ }
37
+
38
+ function getClientState(
39
+ context: {{pascalCase}}Context
40
+ ): {{pascalCase}}ClientState {
41
+ if ( context.client ) {
42
+ return context.client;
43
+ }
44
+
45
+ context.client = {
46
+ bootstrapError: '',
47
+ writeExpiry: 0,
48
+ writeNonce: '',
49
+ writeToken: '',
50
+ };
51
+
52
+ return context.client;
53
+ }
54
+
55
+ function clearBootstrapError(
56
+ context: {{pascalCase}}Context,
57
+ clientState: {{pascalCase}}ClientState
58
+ ): void {
59
+ if ( context.error === clientState.bootstrapError ) {
60
+ context.error = '';
61
+ }
62
+ clientState.bootstrapError = '';
63
+ }
64
+
65
+ function setBootstrapError(
66
+ context: {{pascalCase}}Context,
67
+ clientState: {{pascalCase}}ClientState,
68
+ message: string
69
+ ): void {
70
+ clientState.bootstrapError = message;
71
+ context.error = message;
24
72
  }
25
73
 
26
74
  const { actions, state } = store( '{{slugKebabCase}}', {
27
75
  state: {
28
- canWrite: false,
29
- count: 0,
30
- error: undefined,
31
76
  isHydrated: false,
32
- isLoading: false,
33
- isSaving: false,
34
- isVisible: true,
35
77
  } as {{pascalCase}}State,
36
78
 
37
79
  actions: {
38
- async loadCounter() {
80
+ async loadState() {
39
81
  const context = getContext< {{pascalCase}}Context >();
40
82
  if ( context.postId <= 0 || ! context.resourceKey ) {
41
83
  return;
42
84
  }
43
85
 
44
- state.isLoading = true;
45
- state.error = undefined;
86
+ context.isLoading = true;
87
+ context.error = '';
46
88
 
47
89
  try {
48
90
  const result = await fetchState( {
49
91
  postId: context.postId,
50
92
  resourceKey: context.resourceKey,
51
93
  }, {
52
- restNonce: context.restNonce,
53
94
  transportTarget: 'frontend',
54
95
  } );
55
96
  if ( ! result.isValid || ! result.data ) {
56
- state.error = result.errors[ 0 ]?.expected ?? 'Unable to load counter';
97
+ context.error = result.errors[ 0 ]?.expected ?? 'Unable to load counter';
57
98
  return;
58
99
  }
59
100
  context.count = result.data.count;
60
- context.storage = result.data.storage;
61
- state.count = result.data.count;
62
101
  } catch ( error ) {
63
- state.error =
102
+ context.error =
64
103
  error instanceof Error ? error.message : 'Unknown loading error';
65
104
  } finally {
66
- state.isLoading = false;
105
+ context.isLoading = false;
106
+ }
107
+ },
108
+ async loadBootstrap() {
109
+ const context = getContext< {{pascalCase}}Context >();
110
+ const clientState = getClientState( context );
111
+ if ( context.postId <= 0 || ! context.resourceKey ) {
112
+ context.bootstrapReady = true;
113
+ context.canWrite = false;
114
+ clientState.bootstrapError = '';
115
+ clientState.writeExpiry = 0;
116
+ clientState.writeNonce = '';
117
+ clientState.writeToken = '';
118
+ return;
119
+ }
120
+
121
+ context.isBootstrapping = true;
122
+
123
+ let bootstrapSucceeded = false;
124
+ let lastBootstrapError =
125
+ 'Unable to initialize write access';
126
+
127
+ for ( let attempt = 1; attempt <= BOOTSTRAP_MAX_ATTEMPTS; attempt += 1 ) {
128
+ try {
129
+ const result = await fetchBootstrap( {
130
+ postId: context.postId,
131
+ resourceKey: context.resourceKey,
132
+ }, {
133
+ transportTarget: 'frontend',
134
+ } );
135
+ if ( ! result.isValid || ! result.data ) {
136
+ lastBootstrapError =
137
+ result.errors[ 0 ]?.expected ??
138
+ 'Unable to initialize write access';
139
+ if ( attempt < BOOTSTRAP_MAX_ATTEMPTS ) {
140
+ await waitForBootstrapRetry(
141
+ BOOTSTRAP_RETRY_DELAYS_MS[ attempt - 1 ] ?? 750
142
+ );
143
+ continue;
144
+ }
145
+ break;
146
+ }
147
+
148
+ clientState.writeExpiry =
149
+ typeof result.data.publicWriteExpiresAt === 'number' &&
150
+ result.data.publicWriteExpiresAt > 0
151
+ ? result.data.publicWriteExpiresAt
152
+ : 0;
153
+ clientState.writeToken =
154
+ typeof result.data.publicWriteToken === 'string' &&
155
+ result.data.publicWriteToken.length > 0
156
+ ? result.data.publicWriteToken
157
+ : '';
158
+ clientState.writeNonce =
159
+ typeof result.data.restNonce === 'string' &&
160
+ result.data.restNonce.length > 0
161
+ ? result.data.restNonce
162
+ : '';
163
+ context.bootstrapReady = true;
164
+ context.canWrite =
165
+ result.data.canWrite === true &&
166
+ (
167
+ context.persistencePolicy === 'authenticated'
168
+ ? clientState.writeNonce.length > 0
169
+ : clientState.writeToken.length > 0 &&
170
+ ! hasExpiredPublicWriteToken( clientState.writeExpiry )
171
+ );
172
+ clearBootstrapError( context, clientState );
173
+ bootstrapSucceeded = true;
174
+ break;
175
+ } catch ( error ) {
176
+ lastBootstrapError =
177
+ error instanceof Error ? error.message : 'Unknown bootstrap error';
178
+ if ( attempt < BOOTSTRAP_MAX_ATTEMPTS ) {
179
+ await waitForBootstrapRetry(
180
+ BOOTSTRAP_RETRY_DELAYS_MS[ attempt - 1 ] ?? 750
181
+ );
182
+ continue;
183
+ }
184
+ break;
185
+ }
67
186
  }
187
+
188
+ if ( ! bootstrapSucceeded ) {
189
+ context.bootstrapReady = false;
190
+ context.canWrite = false;
191
+ clientState.writeExpiry = 0;
192
+ clientState.writeNonce = '';
193
+ clientState.writeToken = '';
194
+ setBootstrapError( context, clientState, lastBootstrapError );
195
+ }
196
+ context.isBootstrapping = false;
68
197
  },
69
198
  async increment() {
70
199
  const context = getContext< {{pascalCase}}Context >();
200
+ const clientState = getClientState( context );
71
201
  if ( context.postId <= 0 || ! context.resourceKey ) {
72
202
  return;
73
203
  }
74
- if ( hasExpiredPublicWriteToken( context ) ) {
204
+ if ( ! context.bootstrapReady ) {
205
+ await actions.loadBootstrap();
206
+ }
207
+ if ( ! context.bootstrapReady ) {
208
+ context.error = 'Write access is still initializing.';
209
+ return;
210
+ }
211
+ if (
212
+ context.persistencePolicy === 'public' &&
213
+ hasExpiredPublicWriteToken( clientState.writeExpiry )
214
+ ) {
215
+ await actions.loadBootstrap();
216
+ }
217
+ if (
218
+ context.persistencePolicy === 'public' &&
219
+ hasExpiredPublicWriteToken( clientState.writeExpiry )
220
+ ) {
75
221
  context.canWrite = false;
76
- state.canWrite = false;
77
- state.error = getWriteBlockedMessage( context );
222
+ context.error = getWriteBlockedMessage( context );
78
223
  return;
79
224
  }
80
- if ( ! context.canWrite || ! state.canWrite ) {
81
- state.error = getWriteBlockedMessage( context );
225
+ if ( ! context.canWrite ) {
226
+ context.error = getWriteBlockedMessage( context );
82
227
  return;
83
228
  }
84
229
 
85
- state.isSaving = true;
86
- state.error = undefined;
230
+ context.isSaving = true;
231
+ context.error = '';
87
232
 
88
233
  try {
89
234
  const result = await writeState( {
@@ -95,27 +240,28 @@ const { actions, state } = store( '{{slugKebabCase}}', {
95
240
  : undefined,
96
241
  publicWriteToken:
97
242
  context.persistencePolicy === 'public' &&
98
- typeof context.publicWriteToken === 'string' &&
99
- context.publicWriteToken.length > 0
100
- ? context.publicWriteToken
243
+ clientState.writeToken.length > 0
244
+ ? clientState.writeToken
101
245
  : undefined,
102
246
  resourceKey: context.resourceKey,
103
247
  }, {
104
- restNonce: context.restNonce,
248
+ restNonce:
249
+ clientState.writeNonce.length > 0
250
+ ? clientState.writeNonce
251
+ : undefined,
105
252
  transportTarget: 'frontend',
106
253
  } );
107
254
  if ( ! result.isValid || ! result.data ) {
108
- state.error = result.errors[ 0 ]?.expected ?? 'Unable to update counter';
255
+ context.error = result.errors[ 0 ]?.expected ?? 'Unable to update counter';
109
256
  return;
110
257
  }
111
258
  context.count = result.data.count;
112
259
  context.storage = result.data.storage;
113
- state.count = result.data.count;
114
260
  } catch ( error ) {
115
- state.error =
261
+ context.error =
116
262
  error instanceof Error ? error.message : 'Unknown update error';
117
263
  } finally {
118
- state.isSaving = false;
264
+ context.isSaving = false;
119
265
  }
120
266
  },
121
267
  },
@@ -123,18 +269,29 @@ const { actions, state } = store( '{{slugKebabCase}}', {
123
269
  callbacks: {
124
270
  init() {
125
271
  const context = getContext< {{pascalCase}}Context >();
126
- context.canWrite =
127
- context.canWrite && ! hasExpiredPublicWriteToken( context );
128
- state.canWrite = context.canWrite;
129
- state.isVisible = context.isVisible;
130
- state.count = context.count;
272
+ context.client = {
273
+ bootstrapError: '',
274
+ writeExpiry: 0,
275
+ writeNonce: '',
276
+ writeToken: '',
277
+ };
278
+ context.bootstrapReady = false;
279
+ context.canWrite = false;
280
+ context.count = 0;
281
+ context.error = '';
282
+ context.isBootstrapping = false;
283
+ context.isLoading = false;
284
+ context.isSaving = false;
131
285
  },
132
286
  mounted() {
133
287
  state.isHydrated = true;
134
288
  if ( typeof document !== 'undefined' ) {
135
289
  document.documentElement.dataset[ '{{slugCamelCase}}Hydrated' ] = 'true';
136
290
  }
137
- void actions.loadCounter();
291
+ void Promise.allSettled( [
292
+ actions.loadState(),
293
+ actions.loadBootstrap(),
294
+ ] );
138
295
  },
139
296
  },
140
297
  } );
@@ -200,6 +200,126 @@ function {{phpPrefix}}_build_state_response( $post_id, $resource_key, $count ) {
200
200
  );
201
201
  }
202
202
 
203
+ function {{phpPrefix}}_block_tree_has_resource_key( $blocks, $block_name, $resource_key ) {
204
+ if ( ! is_array( $blocks ) ) {
205
+ return false;
206
+ }
207
+
208
+ foreach ( $blocks as $block ) {
209
+ if ( ! is_array( $block ) ) {
210
+ continue;
211
+ }
212
+
213
+ if ( (string) ( $block['blockName'] ?? '' ) === $block_name ) {
214
+ $attributes = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
215
+ $candidate_resource_key = array_key_exists( 'resourceKey', $attributes )
216
+ ? (string) $attributes['resourceKey']
217
+ : 'primary';
218
+ if ( (string) $resource_key === $candidate_resource_key ) {
219
+ return true;
220
+ }
221
+ }
222
+
223
+ if (
224
+ isset( $block['innerBlocks'] ) &&
225
+ {{phpPrefix}}_block_tree_has_resource_key( $block['innerBlocks'], $block_name, $resource_key )
226
+ ) {
227
+ return true;
228
+ }
229
+ }
230
+
231
+ return false;
232
+ }
233
+
234
+ function {{phpPrefix}}_get_rendered_block_instance_key( $post_id, $block_name, $resource_key ) {
235
+ return 'wpt_pri_' . md5( implode( '|', array( (string) $block_name, (int) $post_id, (string) $resource_key ) ) );
236
+ }
237
+
238
+ function {{phpPrefix}}_record_rendered_block_instance( $post_id, $block_name, $resource_key ) {
239
+ if ( $post_id <= 0 || '' === (string) $resource_key || '' === (string) $block_name ) {
240
+ return;
241
+ }
242
+
243
+ set_transient(
244
+ {{phpPrefix}}_get_rendered_block_instance_key( $post_id, $block_name, $resource_key ),
245
+ 1,
246
+ 5 * MINUTE_IN_SECONDS
247
+ );
248
+ }
249
+
250
+ function {{phpPrefix}}_has_rendered_block_instance( $post_id, $resource_key ) {
251
+ if ( $post_id <= 0 || '' === (string) $resource_key ) {
252
+ return false;
253
+ }
254
+
255
+ if (
256
+ false !== get_transient(
257
+ {{phpPrefix}}_get_rendered_block_instance_key(
258
+ $post_id,
259
+ '{{namespace}}/{{slugKebabCase}}',
260
+ $resource_key
261
+ )
262
+ )
263
+ ) {
264
+ return true;
265
+ }
266
+
267
+ $post = get_post( $post_id );
268
+ if ( ! ( $post instanceof WP_Post ) ) {
269
+ return false;
270
+ }
271
+
272
+ if ( ! is_post_publicly_viewable( $post ) ) {
273
+ return false;
274
+ }
275
+
276
+ return {{phpPrefix}}_block_tree_has_resource_key(
277
+ parse_blocks( (string) $post->post_content ),
278
+ '{{namespace}}/{{slugKebabCase}}',
279
+ (string) $resource_key
280
+ );
281
+ }
282
+
283
+ function {{phpPrefix}}_build_bootstrap_response( $post_id, $resource_key ) {
284
+ $response = array(
285
+ 'canWrite' => false,
286
+ );
287
+ $post = get_post( $post_id );
288
+
289
+ if (
290
+ $post_id <= 0 ||
291
+ ! ( $post instanceof WP_Post ) ||
292
+ ! is_post_publicly_viewable( $post ) ||
293
+ ! function_exists( '{{phpPrefix}}_create_public_write_token' ) ||
294
+ ! {{phpPrefix}}_has_rendered_block_instance( (int) $post_id, (string) $resource_key )
295
+ ) {
296
+ return $response;
297
+ }
298
+
299
+ $public_write = {{phpPrefix}}_create_public_write_token( (int) $post_id, (string) $resource_key );
300
+ if ( ! is_array( $public_write ) ) {
301
+ return $response;
302
+ }
303
+
304
+ $token = isset( $public_write['token'] ) ? (string) $public_write['token'] : '';
305
+ $expires_at = isset( $public_write['expiresAt'] ) ? (int) $public_write['expiresAt'] : 0;
306
+ $is_expired = $expires_at > 0 && $expires_at <= time();
307
+
308
+ if ( '' !== $token && $expires_at > 0 && ! $is_expired ) {
309
+ $response['publicWriteToken'] = $token;
310
+ $response['canWrite'] = true;
311
+ }
312
+
313
+ if ( $expires_at > 0 ) {
314
+ $response['publicWriteExpiresAt'] = $expires_at;
315
+ if ( $is_expired ) {
316
+ $response['canWrite'] = false;
317
+ }
318
+ }
319
+
320
+ return $response;
321
+ }
322
+
203
323
  // Route handlers are the main product-level extension point for request/response shaping.
204
324
  function {{phpPrefix}}_handle_get_state( WP_REST_Request $request ) {
205
325
  $payload = {{phpPrefix}}_validate_and_sanitize_request(
@@ -226,6 +346,33 @@ function {{phpPrefix}}_handle_get_state( WP_REST_Request $request ) {
226
346
  );
227
347
  }
228
348
 
349
+ function {{phpPrefix}}_handle_get_bootstrap( WP_REST_Request $request ) {
350
+ $payload = {{phpPrefix}}_validate_and_sanitize_request(
351
+ array(
352
+ 'postId' => $request->get_param( 'postId' ),
353
+ 'resourceKey' => $request->get_param( 'resourceKey' ),
354
+ ),
355
+ {{phpPrefix}}_get_rest_build_dir(),
356
+ 'bootstrap-query',
357
+ 'query'
358
+ );
359
+
360
+ if ( is_wp_error( $payload ) ) {
361
+ return $payload;
362
+ }
363
+
364
+ $response = rest_ensure_response(
365
+ {{phpPrefix}}_build_bootstrap_response(
366
+ (int) $payload['postId'],
367
+ (string) $payload['resourceKey']
368
+ )
369
+ );
370
+
371
+ $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0, s-maxage=0' );
372
+ $response->header( 'Pragma', 'no-cache' );
373
+ return $response;
374
+ }
375
+
229
376
  function {{phpPrefix}}_handle_write_state( WP_REST_Request $request ) {
230
377
  $payload = {{phpPrefix}}_validate_and_sanitize_request(
231
378
  $request->get_json_params(),
@@ -290,6 +437,18 @@ function {{phpPrefix}}_register_routes() {
290
437
  ),
291
438
  )
292
439
  );
440
+
441
+ register_rest_route(
442
+ '{{namespace}}/v1',
443
+ '/{{slugKebabCase}}/bootstrap',
444
+ array(
445
+ array(
446
+ 'methods' => WP_REST_Server::READABLE,
447
+ 'callback' => '{{phpPrefix}}_handle_get_bootstrap',
448
+ 'permission_callback' => '__return_true',
449
+ ),
450
+ )
451
+ );
293
452
  }
294
453
 
295
454
  function {{phpPrefix}}_register_block() {
@@ -234,7 +234,7 @@ function {{phpPrefix}}_verify_public_write_token( $token, $post_id, $resource_ke
234
234
  if ( time() > $expires_at ) {
235
235
  return new WP_Error(
236
236
  'rest_forbidden',
237
- 'The public write token has expired. Reload the page and try again.',
237
+ 'The public write token has expired. Refresh write access and try again.',
238
238
  array( 'status' => 403 )
239
239
  );
240
240
  }
@@ -158,6 +158,105 @@ function {{phpPrefix}}_build_state_response( $post_id, $resource_key, $count ) {
158
158
  );
159
159
  }
160
160
 
161
+ function {{phpPrefix}}_block_tree_has_resource_key( $blocks, $block_name, $resource_key ) {
162
+ if ( ! is_array( $blocks ) ) {
163
+ return false;
164
+ }
165
+
166
+ foreach ( $blocks as $block ) {
167
+ if ( ! is_array( $block ) ) {
168
+ continue;
169
+ }
170
+
171
+ if ( (string) ( $block['blockName'] ?? '' ) === $block_name ) {
172
+ $attributes = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
173
+ $candidate_resource_key = array_key_exists( 'resourceKey', $attributes )
174
+ ? (string) $attributes['resourceKey']
175
+ : 'primary';
176
+ if ( (string) $resource_key === $candidate_resource_key ) {
177
+ return true;
178
+ }
179
+ }
180
+
181
+ if (
182
+ isset( $block['innerBlocks'] ) &&
183
+ {{phpPrefix}}_block_tree_has_resource_key( $block['innerBlocks'], $block_name, $resource_key )
184
+ ) {
185
+ return true;
186
+ }
187
+ }
188
+
189
+ return false;
190
+ }
191
+
192
+ function {{phpPrefix}}_get_rendered_block_instance_key( $post_id, $block_name, $resource_key ) {
193
+ return 'wpt_pri_' . md5( implode( '|', array( (string) $block_name, (int) $post_id, (string) $resource_key ) ) );
194
+ }
195
+
196
+ function {{phpPrefix}}_record_rendered_block_instance( $post_id, $block_name, $resource_key ) {
197
+ if ( $post_id <= 0 || '' === (string) $resource_key || '' === (string) $block_name ) {
198
+ return;
199
+ }
200
+
201
+ set_transient(
202
+ {{phpPrefix}}_get_rendered_block_instance_key( $post_id, $block_name, $resource_key ),
203
+ 1,
204
+ 5 * MINUTE_IN_SECONDS
205
+ );
206
+ }
207
+
208
+ function {{phpPrefix}}_has_rendered_block_instance( $post_id, $resource_key ) {
209
+ if ( $post_id <= 0 || '' === (string) $resource_key ) {
210
+ return false;
211
+ }
212
+
213
+ if (
214
+ false !== get_transient(
215
+ {{phpPrefix}}_get_rendered_block_instance_key(
216
+ $post_id,
217
+ '{{namespace}}/{{slugKebabCase}}',
218
+ $resource_key
219
+ )
220
+ )
221
+ ) {
222
+ return true;
223
+ }
224
+
225
+ $post = get_post( $post_id );
226
+ if ( ! ( $post instanceof WP_Post ) ) {
227
+ return false;
228
+ }
229
+
230
+ return {{phpPrefix}}_block_tree_has_resource_key(
231
+ parse_blocks( (string) $post->post_content ),
232
+ '{{namespace}}/{{slugKebabCase}}',
233
+ (string) $resource_key
234
+ );
235
+ }
236
+
237
+ function {{phpPrefix}}_build_bootstrap_response( $post_id, $resource_key ) {
238
+ $post = get_post( $post_id );
239
+ $can_read_post =
240
+ $post instanceof WP_Post &&
241
+ (
242
+ is_post_publicly_viewable( $post ) ||
243
+ current_user_can( 'read_post', $post->ID )
244
+ );
245
+ $can_write = $post_id > 0 &&
246
+ is_user_logged_in() &&
247
+ $can_read_post &&
248
+ {{phpPrefix}}_has_rendered_block_instance( (int) $post_id, (string) $resource_key );
249
+ $response = array(
250
+ 'canWrite' => $can_write,
251
+ );
252
+
253
+ if ( $can_write ) {
254
+ $response['restNonce'] = wp_create_nonce( 'wp_rest' );
255
+ }
256
+
257
+ return $response;
258
+ }
259
+
161
260
  function {{phpPrefix}}_handle_get_state( WP_REST_Request $request ) {
162
261
  $payload = {{phpPrefix}}_validate_and_sanitize_request(
163
262
  array(
@@ -183,6 +282,34 @@ function {{phpPrefix}}_handle_get_state( WP_REST_Request $request ) {
183
282
  );
184
283
  }
185
284
 
285
+ function {{phpPrefix}}_handle_get_bootstrap( WP_REST_Request $request ) {
286
+ $payload = {{phpPrefix}}_validate_and_sanitize_request(
287
+ array(
288
+ 'postId' => $request->get_param( 'postId' ),
289
+ 'resourceKey' => $request->get_param( 'resourceKey' ),
290
+ ),
291
+ {{phpPrefix}}_get_rest_build_dir(),
292
+ 'bootstrap-query',
293
+ 'query'
294
+ );
295
+
296
+ if ( is_wp_error( $payload ) ) {
297
+ return $payload;
298
+ }
299
+
300
+ $response = rest_ensure_response(
301
+ {{phpPrefix}}_build_bootstrap_response(
302
+ (int) $payload['postId'],
303
+ (string) $payload['resourceKey']
304
+ )
305
+ );
306
+
307
+ $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0, s-maxage=0' );
308
+ $response->header( 'Pragma', 'no-cache' );
309
+ $response->header( 'Vary', 'Cookie' );
310
+ return $response;
311
+ }
312
+
186
313
  function {{phpPrefix}}_handle_write_state( WP_REST_Request $request ) {
187
314
  $payload = {{phpPrefix}}_validate_and_sanitize_request(
188
315
  $request->get_json_params(),
@@ -231,6 +358,18 @@ function {{phpPrefix}}_register_routes() {
231
358
  ),
232
359
  )
233
360
  );
361
+
362
+ register_rest_route(
363
+ '{{namespace}}/v1',
364
+ '/{{slugKebabCase}}/bootstrap',
365
+ array(
366
+ array(
367
+ 'methods' => WP_REST_Server::READABLE,
368
+ 'callback' => '{{phpPrefix}}_handle_get_bootstrap',
369
+ 'permission_callback' => '__return_true',
370
+ ),
371
+ )
372
+ );
234
373
  }
235
374
 
236
375
  add_action( 'init', '{{phpPrefix}}_ensure_storage_installed' );
@@ -226,7 +226,7 @@ function {{phpPrefix}}_verify_public_write_token( $token, $post_id, $resource_ke
226
226
  if ( time() > (int) ( $payload['exp'] ?? 0 ) ) {
227
227
  return new WP_Error(
228
228
  'rest_forbidden',
229
- 'The public write token has expired. Reload the page and try again.',
229
+ 'The public write token has expired. Refresh write access and try again.',
230
230
  array( 'status' => 403 )
231
231
  );
232
232
  }