@wp-typia/project-tools 0.14.0 → 0.15.1

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 (43) hide show
  1. package/dist/runtime/cli-add.js +26 -70
  2. package/dist/runtime/cli-doctor.js +25 -9
  3. package/dist/runtime/cli-help.js +1 -0
  4. package/dist/runtime/cli-templates.js +10 -0
  5. package/dist/runtime/persistence-rest-artifacts.d.ts +76 -0
  6. package/dist/runtime/persistence-rest-artifacts.js +99 -0
  7. package/dist/runtime/scaffold-onboarding.js +3 -2
  8. package/dist/runtime/scaffold.d.ts +11 -2
  9. package/dist/runtime/scaffold.js +98 -1
  10. package/dist/runtime/schema-core.d.ts +1 -0
  11. package/dist/runtime/schema-core.js +188 -8
  12. package/dist/runtime/template-builtins.js +1 -1
  13. package/dist/runtime/template-registry.d.ts +2 -1
  14. package/dist/runtime/template-registry.js +13 -2
  15. package/package.json +2 -1
  16. package/templates/_shared/compound/core/scripts/add-compound-child.ts.mustache +103 -7
  17. package/templates/_shared/compound/persistence/scripts/block-config.ts.mustache +16 -0
  18. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/api-types.ts.mustache +10 -0
  19. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/api.ts.mustache +23 -0
  20. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/data.ts.mustache +45 -0
  21. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/interactivity.ts.mustache +201 -42
  22. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/render.php.mustache +37 -43
  23. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/types.ts.mustache +13 -8
  24. package/templates/_shared/compound/persistence-auth/{{slugKebabCase}}.php.mustache +139 -0
  25. package/templates/_shared/compound/persistence-public/{{slugKebabCase}}.php.mustache +159 -0
  26. package/templates/_shared/persistence/auth/{{slugKebabCase}}.php.mustache +139 -0
  27. package/templates/_shared/persistence/core/scripts/sync-rest-contracts.ts.mustache +16 -0
  28. package/templates/_shared/persistence/core/src/api-types.ts.mustache +10 -0
  29. package/templates/_shared/persistence/core/src/api-validators.ts.mustache +14 -0
  30. package/templates/_shared/persistence/core/src/api.ts.mustache +48 -6
  31. package/templates/_shared/persistence/core/src/data.ts.mustache +45 -0
  32. package/templates/_shared/persistence/core/src/interactivity.ts.mustache +216 -53
  33. package/templates/_shared/persistence/public/{{slugKebabCase}}.php.mustache +159 -0
  34. package/templates/_shared/rest-helpers/public/inc/rest-public.php.mustache +1 -1
  35. package/templates/_shared/workspace/persistence-auth/server.php.mustache +139 -0
  36. package/templates/_shared/workspace/persistence-public/inc/rest-public.php.mustache +1 -1
  37. package/templates/_shared/workspace/persistence-public/server.php.mustache +159 -0
  38. package/templates/interactivity/src/block.json.mustache +1 -0
  39. package/templates/interactivity/src/editor.scss.mustache +8 -0
  40. package/templates/interactivity/src/index.tsx.mustache +1 -0
  41. package/templates/persistence/src/edit.tsx.mustache +7 -7
  42. package/templates/persistence/src/render.php.mustache +37 -43
  43. package/templates/persistence/src/types.ts.mustache +13 -9
@@ -1,17 +1,23 @@
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';
10
+ import type {
11
+ {{pascalCase}}WriteStateRequest,
12
+ } from './api-types';
6
13
 
7
14
  function hasExpiredPublicWriteToken(
8
- context: {{pascalCase}}Context
15
+ expiresAt?: number
9
16
  ): boolean {
10
17
  return (
11
- context.persistencePolicy === 'public' &&
12
- typeof context.publicWriteExpiresAt === 'number' &&
13
- context.publicWriteExpiresAt > 0 &&
14
- Date.now() >= context.publicWriteExpiresAt * 1000
18
+ typeof expiresAt === 'number' &&
19
+ expiresAt > 0 &&
20
+ Date.now() >= expiresAt * 1000
15
21
  );
16
22
  }
17
23
 
@@ -20,102 +26,248 @@ function getWriteBlockedMessage(
20
26
  ): string {
21
27
  return context.persistencePolicy === 'authenticated'
22
28
  ? 'Sign in to persist this counter.'
23
- : 'Reload the page to refresh this write token.';
29
+ : 'Public writes are temporarily unavailable.';
30
+ }
31
+
32
+ const BOOTSTRAP_MAX_ATTEMPTS = 3;
33
+ const BOOTSTRAP_RETRY_DELAYS_MS = [ 250, 500 ];
34
+
35
+ async function waitForBootstrapRetry( delayMs: number ): Promise< void > {
36
+ await new Promise( ( resolve ) => {
37
+ setTimeout( resolve, delayMs );
38
+ } );
39
+ }
40
+
41
+ function getClientState(
42
+ context: {{pascalCase}}Context
43
+ ): {{pascalCase}}ClientState {
44
+ if ( context.client ) {
45
+ return context.client;
46
+ }
47
+
48
+ context.client = {
49
+ bootstrapError: '',
50
+ writeExpiry: 0,
51
+ writeNonce: '',
52
+ writeToken: '',
53
+ };
54
+
55
+ return context.client;
56
+ }
57
+
58
+ function clearBootstrapError(
59
+ context: {{pascalCase}}Context,
60
+ clientState: {{pascalCase}}ClientState
61
+ ): void {
62
+ if ( context.error === clientState.bootstrapError ) {
63
+ context.error = '';
64
+ }
65
+ clientState.bootstrapError = '';
66
+ }
67
+
68
+ function setBootstrapError(
69
+ context: {{pascalCase}}Context,
70
+ clientState: {{pascalCase}}ClientState,
71
+ message: string
72
+ ): void {
73
+ clientState.bootstrapError = message;
74
+ context.error = message;
24
75
  }
25
76
 
26
77
  const { actions, state } = store( '{{slugKebabCase}}', {
27
78
  state: {
28
- canWrite: false,
29
- count: 0,
30
- error: undefined,
31
79
  isHydrated: false,
32
- isLoading: false,
33
- isSaving: false,
34
- isVisible: true,
35
80
  } as {{pascalCase}}State,
36
81
 
37
82
  actions: {
38
- async loadCounter() {
83
+ async loadState() {
39
84
  const context = getContext< {{pascalCase}}Context >();
40
85
  if ( context.postId <= 0 || ! context.resourceKey ) {
41
86
  return;
42
87
  }
43
88
 
44
- state.isLoading = true;
45
- state.error = undefined;
89
+ context.isLoading = true;
90
+ context.error = '';
46
91
 
47
92
  try {
48
93
  const result = await fetchState( {
49
94
  postId: context.postId,
50
95
  resourceKey: context.resourceKey,
51
96
  }, {
52
- restNonce: context.restNonce,
53
97
  transportTarget: 'frontend',
54
98
  } );
55
99
  if ( ! result.isValid || ! result.data ) {
56
- state.error = result.errors[ 0 ]?.expected ?? 'Unable to load counter';
100
+ context.error = result.errors[ 0 ]?.expected ?? 'Unable to load counter';
57
101
  return;
58
102
  }
59
103
  context.count = result.data.count;
60
- context.storage = result.data.storage;
61
- state.count = result.data.count;
62
104
  } catch ( error ) {
63
- state.error =
105
+ context.error =
64
106
  error instanceof Error ? error.message : 'Unknown loading error';
65
107
  } finally {
66
- state.isLoading = false;
108
+ context.isLoading = false;
109
+ }
110
+ },
111
+ async loadBootstrap() {
112
+ const context = getContext< {{pascalCase}}Context >();
113
+ const clientState = getClientState( context );
114
+ if ( context.postId <= 0 || ! context.resourceKey ) {
115
+ context.bootstrapReady = true;
116
+ context.canWrite = false;
117
+ clientState.bootstrapError = '';
118
+ clientState.writeExpiry = 0;
119
+ clientState.writeNonce = '';
120
+ clientState.writeToken = '';
121
+ return;
122
+ }
123
+
124
+ context.isBootstrapping = true;
125
+
126
+ let bootstrapSucceeded = false;
127
+ let lastBootstrapError =
128
+ 'Unable to initialize write access';
129
+ const includeRestNonce = {{isAuthenticatedPersistencePolicy}};
130
+
131
+ for ( let attempt = 1; attempt <= BOOTSTRAP_MAX_ATTEMPTS; attempt += 1 ) {
132
+ try {
133
+ const result = await fetchBootstrap( {
134
+ postId: context.postId,
135
+ resourceKey: context.resourceKey,
136
+ }, {
137
+ transportTarget: 'frontend',
138
+ } );
139
+ if ( ! result.isValid || ! result.data ) {
140
+ lastBootstrapError =
141
+ result.errors[ 0 ]?.expected ??
142
+ 'Unable to initialize write access';
143
+ if ( attempt < BOOTSTRAP_MAX_ATTEMPTS ) {
144
+ await waitForBootstrapRetry(
145
+ BOOTSTRAP_RETRY_DELAYS_MS[ attempt - 1 ] ?? 750
146
+ );
147
+ continue;
148
+ }
149
+ break;
150
+ }
151
+
152
+ clientState.writeExpiry =
153
+ typeof result.data.publicWriteExpiresAt === 'number' &&
154
+ result.data.publicWriteExpiresAt > 0
155
+ ? result.data.publicWriteExpiresAt
156
+ : 0;
157
+ clientState.writeToken =
158
+ typeof result.data.publicWriteToken === 'string' &&
159
+ result.data.publicWriteToken.length > 0
160
+ ? result.data.publicWriteToken
161
+ : '';
162
+ clientState.writeNonce =
163
+ includeRestNonce &&
164
+ 'restNonce' in result.data &&
165
+ typeof result.data.restNonce === 'string' &&
166
+ result.data.restNonce.length > 0
167
+ ? result.data.restNonce
168
+ : '';
169
+ context.bootstrapReady = true;
170
+ context.canWrite =
171
+ result.data.canWrite === true &&
172
+ (
173
+ context.persistencePolicy === 'authenticated'
174
+ ? clientState.writeNonce.length > 0
175
+ : clientState.writeToken.length > 0 &&
176
+ ! hasExpiredPublicWriteToken( clientState.writeExpiry )
177
+ );
178
+ clearBootstrapError( context, clientState );
179
+ bootstrapSucceeded = true;
180
+ break;
181
+ } catch ( error ) {
182
+ lastBootstrapError =
183
+ error instanceof Error ? error.message : 'Unknown bootstrap error';
184
+ if ( attempt < BOOTSTRAP_MAX_ATTEMPTS ) {
185
+ await waitForBootstrapRetry(
186
+ BOOTSTRAP_RETRY_DELAYS_MS[ attempt - 1 ] ?? 750
187
+ );
188
+ continue;
189
+ }
190
+ break;
191
+ }
192
+ }
193
+
194
+ if ( ! bootstrapSucceeded ) {
195
+ context.bootstrapReady = false;
196
+ context.canWrite = false;
197
+ clientState.writeExpiry = 0;
198
+ clientState.writeNonce = '';
199
+ clientState.writeToken = '';
200
+ setBootstrapError( context, clientState, lastBootstrapError );
67
201
  }
202
+ context.isBootstrapping = false;
68
203
  },
69
204
  async increment() {
70
205
  const context = getContext< {{pascalCase}}Context >();
206
+ const clientState = getClientState( context );
71
207
  if ( context.postId <= 0 || ! context.resourceKey ) {
72
208
  return;
73
209
  }
74
- if ( hasExpiredPublicWriteToken( context ) ) {
210
+ if ( ! context.bootstrapReady ) {
211
+ await actions.loadBootstrap();
212
+ }
213
+ if ( ! context.bootstrapReady ) {
214
+ context.error = 'Write access is still initializing.';
215
+ return;
216
+ }
217
+ if (
218
+ context.persistencePolicy === 'public' &&
219
+ hasExpiredPublicWriteToken( clientState.writeExpiry )
220
+ ) {
221
+ await actions.loadBootstrap();
222
+ }
223
+ if (
224
+ context.persistencePolicy === 'public' &&
225
+ hasExpiredPublicWriteToken( clientState.writeExpiry )
226
+ ) {
75
227
  context.canWrite = false;
76
- state.canWrite = false;
77
- state.error = getWriteBlockedMessage( context );
228
+ context.error = getWriteBlockedMessage( context );
78
229
  return;
79
230
  }
80
- if ( ! context.canWrite || ! state.canWrite ) {
81
- state.error = getWriteBlockedMessage( context );
231
+ if ( ! context.canWrite ) {
232
+ context.error = getWriteBlockedMessage( context );
82
233
  return;
83
234
  }
84
235
 
85
- state.isSaving = true;
86
- state.error = undefined;
236
+ context.isSaving = true;
237
+ context.error = '';
87
238
 
88
239
  try {
89
- const result = await writeState( {
240
+ const request = {
90
241
  delta: 1,
91
242
  postId: context.postId,
92
- publicWriteRequestId:
93
- context.persistencePolicy === 'public'
94
- ? generatePublicWriteRequestId()
95
- : undefined,
96
- publicWriteToken:
97
- context.persistencePolicy === 'public' &&
98
- typeof context.publicWriteToken === 'string' &&
99
- context.publicWriteToken.length > 0
100
- ? context.publicWriteToken
101
- : undefined,
102
243
  resourceKey: context.resourceKey,
103
- }, {
104
- restNonce: context.restNonce,
244
+ } as {{pascalCase}}WriteStateRequest;
245
+ if ( {{isPublicPersistencePolicy}} ) {
246
+ request.publicWriteRequestId =
247
+ generatePublicWriteRequestId() as {{pascalCase}}WriteStateRequest[ 'publicWriteRequestId' ];
248
+ if ( clientState.writeToken.length > 0 ) {
249
+ request.publicWriteToken =
250
+ clientState.writeToken as {{pascalCase}}WriteStateRequest[ 'publicWriteToken' ];
251
+ }
252
+ }
253
+ const result = await writeState( request, {
254
+ restNonce:
255
+ clientState.writeNonce.length > 0
256
+ ? clientState.writeNonce
257
+ : undefined,
105
258
  transportTarget: 'frontend',
106
259
  } );
107
260
  if ( ! result.isValid || ! result.data ) {
108
- state.error = result.errors[ 0 ]?.expected ?? 'Unable to update counter';
261
+ context.error = result.errors[ 0 ]?.expected ?? 'Unable to update counter';
109
262
  return;
110
263
  }
111
264
  context.count = result.data.count;
112
265
  context.storage = result.data.storage;
113
- state.count = result.data.count;
114
266
  } catch ( error ) {
115
- state.error =
267
+ context.error =
116
268
  error instanceof Error ? error.message : 'Unknown update error';
117
269
  } finally {
118
- state.isSaving = false;
270
+ context.isSaving = false;
119
271
  }
120
272
  },
121
273
  },
@@ -123,18 +275,29 @@ const { actions, state } = store( '{{slugKebabCase}}', {
123
275
  callbacks: {
124
276
  init() {
125
277
  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;
278
+ context.client = {
279
+ bootstrapError: '',
280
+ writeExpiry: 0,
281
+ writeNonce: '',
282
+ writeToken: '',
283
+ };
284
+ context.bootstrapReady = false;
285
+ context.canWrite = false;
286
+ context.count = 0;
287
+ context.error = '';
288
+ context.isBootstrapping = false;
289
+ context.isLoading = false;
290
+ context.isSaving = false;
131
291
  },
132
292
  mounted() {
133
293
  state.isHydrated = true;
134
294
  if ( typeof document !== 'undefined' ) {
135
295
  document.documentElement.dataset[ '{{slugCamelCase}}Hydrated' ] = 'true';
136
296
  }
137
- void actions.loadCounter();
297
+ void Promise.allSettled( [
298
+ actions.loadState(),
299
+ actions.loadBootstrap(),
300
+ ] );
138
301
  },
139
302
  },
140
303
  } );
@@ -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' );