@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,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,69 +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
77
  } as {{pascalCase}}State,
35
78
 
36
79
  actions: {
37
- async loadCounter() {
80
+ async loadState() {
38
81
  const context = getContext< {{pascalCase}}Context >();
39
82
  if ( context.postId <= 0 || ! context.resourceKey ) {
40
83
  return;
41
84
  }
42
85
 
43
- state.isLoading = true;
44
- state.error = undefined;
86
+ context.isLoading = true;
87
+ context.error = '';
45
88
 
46
89
  try {
47
90
  const result = await fetchState( {
48
91
  postId: context.postId,
49
92
  resourceKey: context.resourceKey,
50
93
  }, {
51
- restNonce: context.restNonce,
52
94
  transportTarget: 'frontend',
53
95
  } );
54
96
  if ( ! result.isValid || ! result.data ) {
55
- state.error = result.errors[ 0 ]?.expected ?? 'Unable to load counter';
97
+ context.error = result.errors[ 0 ]?.expected ?? 'Unable to load counter';
56
98
  return;
57
99
  }
58
100
  context.count = result.data.count;
59
- context.storage = result.data.storage;
60
- state.count = result.data.count;
61
101
  } catch ( error ) {
62
- state.error =
102
+ context.error =
63
103
  error instanceof Error ? error.message : 'Unknown loading error';
64
104
  } finally {
65
- 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
+ }
66
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;
67
197
  },
68
198
  async increment() {
69
199
  const context = getContext< {{pascalCase}}Context >();
200
+ const clientState = getClientState( context );
70
201
  if ( context.postId <= 0 || ! context.resourceKey ) {
71
202
  return;
72
203
  }
73
- 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
+ ) {
74
221
  context.canWrite = false;
75
- state.canWrite = false;
76
- state.error = getWriteBlockedMessage( context );
222
+ context.error = getWriteBlockedMessage( context );
77
223
  return;
78
224
  }
79
- if ( ! context.canWrite || ! state.canWrite ) {
80
- state.error = getWriteBlockedMessage( context );
225
+ if ( ! context.canWrite ) {
226
+ context.error = getWriteBlockedMessage( context );
81
227
  return;
82
228
  }
83
229
 
84
- state.isSaving = true;
85
- state.error = undefined;
230
+ context.isSaving = true;
231
+ context.error = '';
86
232
 
87
233
  try {
88
234
  const result = await writeState( {
@@ -94,27 +240,28 @@ const { actions, state } = store( '{{slugKebabCase}}', {
94
240
  : undefined,
95
241
  publicWriteToken:
96
242
  context.persistencePolicy === 'public' &&
97
- typeof context.publicWriteToken === 'string' &&
98
- context.publicWriteToken.length > 0
99
- ? context.publicWriteToken
243
+ clientState.writeToken.length > 0
244
+ ? clientState.writeToken
100
245
  : undefined,
101
246
  resourceKey: context.resourceKey,
102
247
  }, {
103
- restNonce: context.restNonce,
248
+ restNonce:
249
+ clientState.writeNonce.length > 0
250
+ ? clientState.writeNonce
251
+ : undefined,
104
252
  transportTarget: 'frontend',
105
253
  } );
106
254
  if ( ! result.isValid || ! result.data ) {
107
- state.error = result.errors[ 0 ]?.expected ?? 'Unable to update counter';
255
+ context.error = result.errors[ 0 ]?.expected ?? 'Unable to update counter';
108
256
  return;
109
257
  }
110
258
  context.count = result.data.count;
111
259
  context.storage = result.data.storage;
112
- state.count = result.data.count;
113
260
  } catch ( error ) {
114
- state.error =
261
+ context.error =
115
262
  error instanceof Error ? error.message : 'Unknown update error';
116
263
  } finally {
117
- state.isSaving = false;
264
+ context.isSaving = false;
118
265
  }
119
266
  },
120
267
  },
@@ -122,17 +269,29 @@ const { actions, state } = store( '{{slugKebabCase}}', {
122
269
  callbacks: {
123
270
  init() {
124
271
  const context = getContext< {{pascalCase}}Context >();
125
- context.canWrite =
126
- context.canWrite && ! hasExpiredPublicWriteToken( context );
127
- state.canWrite = context.canWrite;
128
- 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;
129
285
  },
130
286
  mounted() {
131
287
  state.isHydrated = true;
132
288
  if ( typeof document !== 'undefined' ) {
133
289
  document.documentElement.dataset[ '{{slugCamelCase}}Hydrated' ] = 'true';
134
290
  }
135
- void actions.loadCounter();
291
+ void Promise.allSettled( [
292
+ actions.loadState(),
293
+ actions.loadBootstrap(),
294
+ ] );
136
295
  },
137
296
  },
138
297
  } );
@@ -32,50 +32,42 @@ $post_id = is_object( $block ) && isset( $block->context['postId'] )
32
32
  : (int) get_queried_object_id();
33
33
  $storage_mode = '{{dataStorageMode}}';
34
34
  $persistence_policy = '{{persistencePolicy}}';
35
- $can_write = false;
35
+
36
+ {{phpPrefix}}_record_rendered_block_instance(
37
+ (int) $post_id,
38
+ '{{namespace}}/{{slugKebabCase}}',
39
+ $resource_key
40
+ );
41
+
36
42
  $notice_message = 'authenticated' === $persistence_policy
37
43
  ? __( 'Sign in to persist this counter.', '{{textDomain}}' )
38
- : __( 'Reload the page to refresh this write token.', '{{textDomain}}' );
44
+ : __( 'Public writes are temporarily unavailable.', '{{textDomain}}' );
39
45
 
40
46
  if ( empty( $validation['valid'] ) || '' === $resource_key ) {
41
47
  return '';
42
48
  }
43
49
 
44
50
  $context = array(
45
- 'buttonLabel' => $button_label,
46
- 'canWrite' => false,
47
- 'count' => 0,
48
- 'persistencePolicy' => $persistence_policy,
49
- 'postId' => (int) $post_id,
50
- 'resourceKey' => $resource_key,
51
- 'showCount' => $show_count,
52
- 'storage' => $storage_mode,
51
+ 'bootstrapReady' => false,
52
+ 'buttonLabel' => $button_label,
53
+ 'canWrite' => false,
54
+ 'client' => array(
55
+ 'writeExpiry' => 0,
56
+ 'writeNonce' => '',
57
+ 'writeToken' => '',
58
+ ),
59
+ 'count' => 0,
60
+ 'error' => '',
61
+ 'isBootstrapping' => false,
62
+ 'isLoading' => false,
63
+ 'isSaving' => false,
64
+ 'persistencePolicy' => $persistence_policy,
65
+ 'postId' => (int) $post_id,
66
+ 'resourceKey' => $resource_key,
67
+ 'showCount' => $show_count,
68
+ 'storage' => $storage_mode,
53
69
  );
54
70
 
55
- if ( 'authenticated' === $persistence_policy ) {
56
- $can_write = $post_id > 0 && is_user_logged_in();
57
- if ( $can_write ) {
58
- $context['restNonce'] = wp_create_nonce( 'wp_rest' );
59
- }
60
- } elseif ( $post_id > 0 && function_exists( '{{phpPrefix}}_create_public_write_token' ) ) {
61
- $public_write = {{phpPrefix}}_create_public_write_token( (int) $post_id, $resource_key );
62
- if ( is_array( $public_write ) ) {
63
- $token = isset( $public_write['token'] ) ? (string) $public_write['token'] : '';
64
- $expires_at = isset( $public_write['expiresAt'] ) ? (int) $public_write['expiresAt'] : 0;
65
-
66
- if ( '' !== $token ) {
67
- $context['publicWriteToken'] = $token;
68
- $can_write = true;
69
- }
70
-
71
- if ( $expires_at > 0 ) {
72
- $context['publicWriteExpiresAt'] = $expires_at;
73
- }
74
- }
75
- }
76
-
77
- $context['canWrite'] = $can_write;
78
-
79
71
  $allowed_inner_html = wp_kses_allowed_html( 'post' );
80
72
 
81
73
  foreach ( $allowed_inner_html as &$allowed_attributes ) {
@@ -119,18 +111,20 @@ $wrapper_attributes = get_block_wrapper_attributes(
119
111
  <?php if ( '' !== $intro ) : ?>
120
112
  <p class="{{cssClassName}}__intro"><?php echo esc_html( $intro ); ?></p>
121
113
  <?php endif; ?>
122
- <?php if ( ! $can_write ) : ?>
123
- <p class="{{cssClassName}}__notice">
124
- <?php echo esc_html( $notice_message ); ?>
125
- </p>
126
- <?php endif; ?>
114
+ <p
115
+ class="{{cssClassName}}__notice"
116
+ data-wp-bind--hidden="!context.bootstrapReady || context.canWrite"
117
+ hidden
118
+ >
119
+ <?php echo esc_html( $notice_message ); ?>
120
+ </p>
127
121
  <p
128
122
  class="{{cssClassName}}__error"
129
123
  role="status"
130
124
  aria-live="polite"
131
125
  aria-atomic="true"
132
- data-wp-bind--hidden="!state.error"
133
- data-wp-text="state.error"
126
+ data-wp-bind--hidden="!context.error"
127
+ data-wp-text="context.error"
134
128
  hidden
135
129
  ></p>
136
130
  <?php if ( $show_count ) : ?>
@@ -140,11 +134,11 @@ $wrapper_attributes = get_block_wrapper_attributes(
140
134
  role="status"
141
135
  aria-live="polite"
142
136
  aria-atomic="true"
143
- data-wp-text="state.count"
137
+ data-wp-text="context.count"
144
138
  >0</span>
145
139
  <button
146
140
  type="button"
147
- <?php echo $can_write ? '' : 'disabled'; ?>
141
+ disabled
148
142
  data-wp-bind--disabled="!context.canWrite"
149
143
  data-wp-on--click="actions.increment"
150
144
  >
@@ -32,25 +32,30 @@ export interface {{pascalCase}}Attributes {
32
32
 
33
33
  export interface {{pascalCase}}Context {
34
34
  buttonLabel: string;
35
+ bootstrapReady: boolean;
35
36
  canWrite: boolean;
36
37
  count: number;
38
+ error: string;
39
+ isBootstrapping: boolean;
40
+ isLoading: boolean;
41
+ isSaving: boolean;
37
42
  persistencePolicy: 'authenticated' | 'public';
38
43
  postId: number;
39
- publicWriteExpiresAt?: number;
40
- publicWriteToken?: string;
41
44
  resourceKey: string;
42
- restNonce?: string;
43
45
  showCount: boolean;
44
46
  storage: 'post-meta' | 'custom-table';
47
+ client?: {{pascalCase}}ClientState;
45
48
  }
46
49
 
47
50
  export interface {{pascalCase}}State {
48
- canWrite: boolean;
49
- count: number;
50
- error?: string;
51
51
  isHydrated: boolean;
52
- isLoading: boolean;
53
- isSaving: boolean;
52
+ }
53
+
54
+ export interface {{pascalCase}}ClientState {
55
+ bootstrapError: string;
56
+ writeExpiry: number;
57
+ writeNonce: string;
58
+ writeToken: string;
54
59
  }
55
60
 
56
61
  export type {{pascalCase}}ValidationResult = ValidationResult< {{pascalCase}}Attributes >;
@@ -192,6 +192,105 @@ function {{phpPrefix}}_build_state_response( $post_id, $resource_key, $count ) {
192
192
  );
193
193
  }
194
194
 
195
+ function {{phpPrefix}}_block_tree_has_resource_key( $blocks, $block_name, $resource_key ) {
196
+ if ( ! is_array( $blocks ) ) {
197
+ return false;
198
+ }
199
+
200
+ foreach ( $blocks as $block ) {
201
+ if ( ! is_array( $block ) ) {
202
+ continue;
203
+ }
204
+
205
+ if ( (string) ( $block['blockName'] ?? '' ) === $block_name ) {
206
+ $attributes = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
207
+ $candidate_resource_key = array_key_exists( 'resourceKey', $attributes )
208
+ ? (string) $attributes['resourceKey']
209
+ : 'primary';
210
+ if ( (string) $resource_key === $candidate_resource_key ) {
211
+ return true;
212
+ }
213
+ }
214
+
215
+ if (
216
+ isset( $block['innerBlocks'] ) &&
217
+ {{phpPrefix}}_block_tree_has_resource_key( $block['innerBlocks'], $block_name, $resource_key )
218
+ ) {
219
+ return true;
220
+ }
221
+ }
222
+
223
+ return false;
224
+ }
225
+
226
+ function {{phpPrefix}}_get_rendered_block_instance_key( $post_id, $block_name, $resource_key ) {
227
+ return 'wpt_pri_' . md5( implode( '|', array( (string) $block_name, (int) $post_id, (string) $resource_key ) ) );
228
+ }
229
+
230
+ function {{phpPrefix}}_record_rendered_block_instance( $post_id, $block_name, $resource_key ) {
231
+ if ( $post_id <= 0 || '' === (string) $resource_key || '' === (string) $block_name ) {
232
+ return;
233
+ }
234
+
235
+ set_transient(
236
+ {{phpPrefix}}_get_rendered_block_instance_key( $post_id, $block_name, $resource_key ),
237
+ 1,
238
+ 5 * MINUTE_IN_SECONDS
239
+ );
240
+ }
241
+
242
+ function {{phpPrefix}}_has_rendered_block_instance( $post_id, $resource_key ) {
243
+ if ( $post_id <= 0 || '' === (string) $resource_key ) {
244
+ return false;
245
+ }
246
+
247
+ if (
248
+ false !== get_transient(
249
+ {{phpPrefix}}_get_rendered_block_instance_key(
250
+ $post_id,
251
+ '{{namespace}}/{{slugKebabCase}}',
252
+ $resource_key
253
+ )
254
+ )
255
+ ) {
256
+ return true;
257
+ }
258
+
259
+ $post = get_post( $post_id );
260
+ if ( ! ( $post instanceof WP_Post ) ) {
261
+ return false;
262
+ }
263
+
264
+ return {{phpPrefix}}_block_tree_has_resource_key(
265
+ parse_blocks( (string) $post->post_content ),
266
+ '{{namespace}}/{{slugKebabCase}}',
267
+ (string) $resource_key
268
+ );
269
+ }
270
+
271
+ function {{phpPrefix}}_build_bootstrap_response( $post_id, $resource_key ) {
272
+ $post = get_post( $post_id );
273
+ $can_read_post =
274
+ $post instanceof WP_Post &&
275
+ (
276
+ is_post_publicly_viewable( $post ) ||
277
+ current_user_can( 'read_post', $post->ID )
278
+ );
279
+ $can_write = $post_id > 0 &&
280
+ is_user_logged_in() &&
281
+ $can_read_post &&
282
+ {{phpPrefix}}_has_rendered_block_instance( (int) $post_id, (string) $resource_key );
283
+ $response = array(
284
+ 'canWrite' => $can_write,
285
+ );
286
+
287
+ if ( $can_write ) {
288
+ $response['restNonce'] = wp_create_nonce( 'wp_rest' );
289
+ }
290
+
291
+ return $response;
292
+ }
293
+
195
294
  // Route handlers are the main product-level extension point for parent-block request/response shaping.
196
295
  function {{phpPrefix}}_handle_get_state( WP_REST_Request $request ) {
197
296
  $payload = {{phpPrefix}}_validate_and_sanitize_request(
@@ -218,6 +317,34 @@ function {{phpPrefix}}_handle_get_state( WP_REST_Request $request ) {
218
317
  );
219
318
  }
220
319
 
320
+ function {{phpPrefix}}_handle_get_bootstrap( WP_REST_Request $request ) {
321
+ $payload = {{phpPrefix}}_validate_and_sanitize_request(
322
+ array(
323
+ 'postId' => $request->get_param( 'postId' ),
324
+ 'resourceKey' => $request->get_param( 'resourceKey' ),
325
+ ),
326
+ {{phpPrefix}}_get_rest_build_dir(),
327
+ 'bootstrap-query',
328
+ 'query'
329
+ );
330
+
331
+ if ( is_wp_error( $payload ) ) {
332
+ return $payload;
333
+ }
334
+
335
+ $response = rest_ensure_response(
336
+ {{phpPrefix}}_build_bootstrap_response(
337
+ (int) $payload['postId'],
338
+ (string) $payload['resourceKey']
339
+ )
340
+ );
341
+
342
+ $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0, s-maxage=0' );
343
+ $response->header( 'Pragma', 'no-cache' );
344
+ $response->header( 'Vary', 'Cookie' );
345
+ return $response;
346
+ }
347
+
221
348
  function {{phpPrefix}}_handle_write_state( WP_REST_Request $request ) {
222
349
  $payload = {{phpPrefix}}_validate_and_sanitize_request(
223
350
  $request->get_json_params(),
@@ -267,6 +394,18 @@ function {{phpPrefix}}_register_routes() {
267
394
  ),
268
395
  )
269
396
  );
397
+
398
+ register_rest_route(
399
+ '{{namespace}}/v1',
400
+ '/{{slugKebabCase}}/bootstrap',
401
+ array(
402
+ array(
403
+ 'methods' => WP_REST_Server::READABLE,
404
+ 'callback' => '{{phpPrefix}}_handle_get_bootstrap',
405
+ 'permission_callback' => '__return_true',
406
+ ),
407
+ )
408
+ );
270
409
  }
271
410
 
272
411
  function {{phpPrefix}}_register_blocks() {