@wp-typia/project-tools 0.13.4 → 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 (31) hide show
  1. package/dist/runtime/scaffold-onboarding.js +8 -4
  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 +51 -38
  10. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/data.ts.mustache +76 -33
  11. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/interactivity.ts.mustache +206 -41
  12. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/render.php.mustache +37 -43
  13. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/transport.ts.mustache +254 -0
  14. package/templates/_shared/compound/persistence/src/blocks/{{slugKebabCase}}/types.ts.mustache +13 -8
  15. package/templates/_shared/compound/persistence-auth/{{slugKebabCase}}.php.mustache +139 -0
  16. package/templates/_shared/compound/persistence-public/{{slugKebabCase}}.php.mustache +159 -0
  17. package/templates/_shared/persistence/auth/{{slugKebabCase}}.php.mustache +139 -0
  18. package/templates/_shared/persistence/core/scripts/sync-rest-contracts.ts.mustache +16 -0
  19. package/templates/_shared/persistence/core/src/api-types.ts.mustache +10 -0
  20. package/templates/_shared/persistence/core/src/api.ts.mustache +51 -38
  21. package/templates/_shared/persistence/core/src/data.ts.mustache +76 -33
  22. package/templates/_shared/persistence/core/src/interactivity.ts.mustache +206 -43
  23. package/templates/_shared/persistence/core/src/transport.ts.mustache +254 -0
  24. package/templates/_shared/persistence/public/{{slugKebabCase}}.php.mustache +159 -0
  25. package/templates/_shared/rest-helpers/public/inc/rest-public.php.mustache +1 -1
  26. package/templates/_shared/workspace/persistence-auth/server.php.mustache +139 -0
  27. package/templates/_shared/workspace/persistence-public/inc/rest-public.php.mustache +1 -1
  28. package/templates/_shared/workspace/persistence-public/server.php.mustache +159 -0
  29. package/templates/persistence/src/edit.tsx.mustache +1 -1
  30. package/templates/persistence/src/render.php.mustache +37 -43
  31. package/templates/persistence/src/types.ts.mustache +13 -9
@@ -0,0 +1,254 @@
1
+ import {
2
+ type EndpointCallOptions,
3
+ resolveRestRouteUrl,
4
+ } from '@wp-typia/rest';
5
+
6
+ export type PersistenceTransportTarget = 'editor' | 'frontend';
7
+ export type PersistenceTransportOperation = 'read' | 'write';
8
+
9
+ export interface PersistenceTransportOptions {
10
+ restNonce?: string;
11
+ transportTarget?: PersistenceTransportTarget;
12
+ }
13
+
14
+ interface EndpointWithPath {
15
+ path: string;
16
+ }
17
+
18
+ type PersistenceTransportResolver = (
19
+ endpoint: EndpointWithPath,
20
+ request?: unknown,
21
+ options?: PersistenceTransportOptions
22
+ ) => EndpointCallOptions;
23
+
24
+ interface PersistenceTransportTargetResolvers {
25
+ read: PersistenceTransportResolver;
26
+ write: PersistenceTransportResolver;
27
+ }
28
+
29
+ // Replace any of these base URLs to route a given target through a contract-compatible proxy or BFF.
30
+ const EDITOR_READ_BASE_URL: string | undefined = undefined;
31
+ const EDITOR_WRITE_BASE_URL: string | undefined = undefined;
32
+ const FRONTEND_READ_BASE_URL: string | undefined = undefined;
33
+ const FRONTEND_WRITE_BASE_URL: string | undefined = undefined;
34
+
35
+ function resolveEndpointUrl(
36
+ endpointPath: string,
37
+ baseUrl?: string
38
+ ): string {
39
+ if ( typeof baseUrl === 'string' && baseUrl.trim().length > 0 ) {
40
+ const origin =
41
+ typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
42
+ const resolvedBaseUrl = new URL( baseUrl.trim(), origin );
43
+ const [ pathWithQuery, hash = '' ] = endpointPath.split( '#', 2 );
44
+ const [ rawPath, rawQuery = '' ] = pathWithQuery.split( '?', 2 );
45
+ const normalizedEndpointPath = rawPath.replace( /^\/+/, '' );
46
+
47
+ if ( resolvedBaseUrl.searchParams.has( 'rest_route' ) ) {
48
+ const restRouteBase = resolvedBaseUrl.searchParams.get( 'rest_route' ) ?? '/';
49
+ const normalizedRestRouteBase = restRouteBase
50
+ .replace( /^\/+/, '' )
51
+ .replace( /\/+$/, '' );
52
+ const nextRestRoute = normalizedRestRouteBase.length > 0
53
+ ? `/${ normalizedRestRouteBase }/${ normalizedEndpointPath }`
54
+ : `/${ normalizedEndpointPath }`;
55
+ resolvedBaseUrl.searchParams.set( 'rest_route', nextRestRoute );
56
+ } else {
57
+ const basePath = resolvedBaseUrl.pathname.endsWith( '/' )
58
+ ? resolvedBaseUrl.pathname
59
+ : `${ resolvedBaseUrl.pathname }/`;
60
+ resolvedBaseUrl.pathname = `${ basePath }${ normalizedEndpointPath }`;
61
+ }
62
+
63
+ for ( const [ key, value ] of new URLSearchParams( rawQuery ) ) {
64
+ resolvedBaseUrl.searchParams.append( key, value );
65
+ }
66
+
67
+ if ( hash ) {
68
+ resolvedBaseUrl.hash = hash;
69
+ }
70
+
71
+ return resolvedBaseUrl.toString();
72
+ }
73
+
74
+ return resolveRestRouteUrl( endpointPath );
75
+ }
76
+
77
+ function isPlainObject(
78
+ value: unknown
79
+ ): value is Record< string, unknown > {
80
+ if ( Object.prototype.toString.call( value ) !== '[object Object]' ) {
81
+ return false;
82
+ }
83
+
84
+ const prototype = Object.getPrototypeOf( value );
85
+ return prototype === null || prototype === Object.prototype;
86
+ }
87
+
88
+ function encodeRequestQuery( request: unknown ): string {
89
+ if ( request === undefined || request === null ) {
90
+ return '';
91
+ }
92
+
93
+ if ( request instanceof URLSearchParams ) {
94
+ return request.toString();
95
+ }
96
+
97
+ if ( ! isPlainObject( request ) ) {
98
+ throw new Error(
99
+ 'Persistence transport read requests must be plain objects or URLSearchParams.'
100
+ );
101
+ }
102
+
103
+ const params = new URLSearchParams();
104
+
105
+ for ( const [ key, value ] of Object.entries( request ) ) {
106
+ if ( value === undefined || value === null ) {
107
+ continue;
108
+ }
109
+
110
+ if ( Array.isArray( value ) ) {
111
+ for ( const item of value ) {
112
+ params.append( key, String( item ) );
113
+ }
114
+ continue;
115
+ }
116
+
117
+ params.set( key, String( value ) );
118
+ }
119
+
120
+ return params.toString();
121
+ }
122
+
123
+ function joinUrlWithQuery(
124
+ url: string,
125
+ query: string
126
+ ): string {
127
+ if ( ! query ) {
128
+ return url;
129
+ }
130
+
131
+ const nextUrl = new URL(
132
+ url,
133
+ typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
134
+ );
135
+
136
+ for ( const [ key, value ] of new URLSearchParams( query ) ) {
137
+ nextUrl.searchParams.append( key, value );
138
+ }
139
+
140
+ return nextUrl.toString();
141
+ }
142
+
143
+ export function resolveRestNonce( fallback?: string ): string | undefined {
144
+ if ( typeof fallback === 'string' && fallback.length > 0 ) {
145
+ return fallback;
146
+ }
147
+
148
+ if ( typeof window === 'undefined' ) {
149
+ return undefined;
150
+ }
151
+
152
+ const wpApiSettings = ( window as typeof window & {
153
+ wpApiSettings?: { nonce?: string };
154
+ } ).wpApiSettings;
155
+
156
+ return typeof wpApiSettings?.nonce === 'string' && wpApiSettings.nonce.length > 0
157
+ ? wpApiSettings.nonce
158
+ : undefined;
159
+ }
160
+
161
+ function buildCallOptions( {
162
+ baseUrl,
163
+ endpoint,
164
+ includeRestNonce,
165
+ operation,
166
+ options,
167
+ request,
168
+ }: {
169
+ baseUrl?: string;
170
+ endpoint: EndpointWithPath;
171
+ includeRestNonce: boolean;
172
+ operation: PersistenceTransportOperation;
173
+ options?: PersistenceTransportOptions;
174
+ request?: unknown;
175
+ } ): EndpointCallOptions {
176
+ const nonce = includeRestNonce ? resolveRestNonce( options?.restNonce ) : undefined;
177
+ const requestOptions: EndpointCallOptions['requestOptions'] = {};
178
+
179
+ if ( nonce ) {
180
+ requestOptions.headers = {
181
+ 'X-WP-Nonce': nonce,
182
+ };
183
+ }
184
+
185
+ if ( typeof baseUrl === 'string' && baseUrl.trim().length > 0 ) {
186
+ const endpointUrl = resolveEndpointUrl( endpoint.path, baseUrl );
187
+ requestOptions.url =
188
+ operation === 'read'
189
+ ? joinUrlWithQuery( endpointUrl, encodeRequestQuery( request ) )
190
+ : endpointUrl;
191
+ }
192
+
193
+ return Object.keys( requestOptions ).length > 0
194
+ ? {
195
+ requestOptions,
196
+ }
197
+ : {};
198
+ }
199
+
200
+ export const persistenceTransportTargets: Record<
201
+ PersistenceTransportTarget,
202
+ PersistenceTransportTargetResolvers
203
+ > = {
204
+ editor: {
205
+ read: ( endpoint, request, options ) =>
206
+ buildCallOptions( {
207
+ baseUrl: EDITOR_READ_BASE_URL,
208
+ endpoint,
209
+ includeRestNonce: {{isAuthenticatedPersistencePolicy}},
210
+ operation: 'read',
211
+ options,
212
+ request,
213
+ } ),
214
+ write: ( endpoint, request, options ) =>
215
+ buildCallOptions( {
216
+ baseUrl: EDITOR_WRITE_BASE_URL,
217
+ endpoint,
218
+ includeRestNonce: {{isAuthenticatedPersistencePolicy}},
219
+ operation: 'write',
220
+ options,
221
+ request,
222
+ } ),
223
+ },
224
+ frontend: {
225
+ read: ( endpoint, request, options ) =>
226
+ buildCallOptions( {
227
+ baseUrl: FRONTEND_READ_BASE_URL,
228
+ endpoint,
229
+ includeRestNonce: false,
230
+ operation: 'read',
231
+ options,
232
+ request,
233
+ } ),
234
+ write: ( endpoint, request, options ) =>
235
+ buildCallOptions( {
236
+ baseUrl: FRONTEND_WRITE_BASE_URL,
237
+ endpoint,
238
+ includeRestNonce: {{isAuthenticatedPersistencePolicy}},
239
+ operation: 'write',
240
+ options,
241
+ request,
242
+ } ),
243
+ },
244
+ };
245
+
246
+ export function resolveTransportCallOptions(
247
+ target: PersistenceTransportTarget,
248
+ operation: PersistenceTransportOperation,
249
+ endpoint: EndpointWithPath,
250
+ request?: unknown,
251
+ options?: PersistenceTransportOptions
252
+ ): EndpointCallOptions {
253
+ return persistenceTransportTargets[ target ][ operation ]( endpoint, request, options );
254
+ }
@@ -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() {
@@ -195,6 +195,126 @@ function {{phpPrefix}}_build_state_response( $post_id, $resource_key, $count ) {
195
195
  );
196
196
  }
197
197
 
198
+ function {{phpPrefix}}_block_tree_has_resource_key( $blocks, $block_name, $resource_key ) {
199
+ if ( ! is_array( $blocks ) ) {
200
+ return false;
201
+ }
202
+
203
+ foreach ( $blocks as $block ) {
204
+ if ( ! is_array( $block ) ) {
205
+ continue;
206
+ }
207
+
208
+ if ( (string) ( $block['blockName'] ?? '' ) === $block_name ) {
209
+ $attributes = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
210
+ $candidate_resource_key = array_key_exists( 'resourceKey', $attributes )
211
+ ? (string) $attributes['resourceKey']
212
+ : 'primary';
213
+ if ( (string) $resource_key === $candidate_resource_key ) {
214
+ return true;
215
+ }
216
+ }
217
+
218
+ if (
219
+ isset( $block['innerBlocks'] ) &&
220
+ {{phpPrefix}}_block_tree_has_resource_key( $block['innerBlocks'], $block_name, $resource_key )
221
+ ) {
222
+ return true;
223
+ }
224
+ }
225
+
226
+ return false;
227
+ }
228
+
229
+ function {{phpPrefix}}_get_rendered_block_instance_key( $post_id, $block_name, $resource_key ) {
230
+ return 'wpt_pri_' . md5( implode( '|', array( (string) $block_name, (int) $post_id, (string) $resource_key ) ) );
231
+ }
232
+
233
+ function {{phpPrefix}}_record_rendered_block_instance( $post_id, $block_name, $resource_key ) {
234
+ if ( $post_id <= 0 || '' === (string) $resource_key || '' === (string) $block_name ) {
235
+ return;
236
+ }
237
+
238
+ set_transient(
239
+ {{phpPrefix}}_get_rendered_block_instance_key( $post_id, $block_name, $resource_key ),
240
+ 1,
241
+ 5 * MINUTE_IN_SECONDS
242
+ );
243
+ }
244
+
245
+ function {{phpPrefix}}_has_rendered_block_instance( $post_id, $resource_key ) {
246
+ if ( $post_id <= 0 || '' === (string) $resource_key ) {
247
+ return false;
248
+ }
249
+
250
+ if (
251
+ false !== get_transient(
252
+ {{phpPrefix}}_get_rendered_block_instance_key(
253
+ $post_id,
254
+ '{{namespace}}/{{slugKebabCase}}',
255
+ $resource_key
256
+ )
257
+ )
258
+ ) {
259
+ return true;
260
+ }
261
+
262
+ $post = get_post( $post_id );
263
+ if ( ! ( $post instanceof WP_Post ) ) {
264
+ return false;
265
+ }
266
+
267
+ if ( ! is_post_publicly_viewable( $post ) ) {
268
+ return false;
269
+ }
270
+
271
+ return {{phpPrefix}}_block_tree_has_resource_key(
272
+ parse_blocks( (string) $post->post_content ),
273
+ '{{namespace}}/{{slugKebabCase}}',
274
+ (string) $resource_key
275
+ );
276
+ }
277
+
278
+ function {{phpPrefix}}_build_bootstrap_response( $post_id, $resource_key ) {
279
+ $response = array(
280
+ 'canWrite' => false,
281
+ );
282
+ $post = get_post( $post_id );
283
+
284
+ if (
285
+ $post_id <= 0 ||
286
+ ! ( $post instanceof WP_Post ) ||
287
+ ! is_post_publicly_viewable( $post ) ||
288
+ ! function_exists( '{{phpPrefix}}_create_public_write_token' ) ||
289
+ ! {{phpPrefix}}_has_rendered_block_instance( (int) $post_id, (string) $resource_key )
290
+ ) {
291
+ return $response;
292
+ }
293
+
294
+ $public_write = {{phpPrefix}}_create_public_write_token( (int) $post_id, (string) $resource_key );
295
+ if ( ! is_array( $public_write ) ) {
296
+ return $response;
297
+ }
298
+
299
+ $token = isset( $public_write['token'] ) ? (string) $public_write['token'] : '';
300
+ $expires_at = isset( $public_write['expiresAt'] ) ? (int) $public_write['expiresAt'] : 0;
301
+ $is_expired = $expires_at > 0 && $expires_at <= time();
302
+
303
+ if ( '' !== $token && $expires_at > 0 && ! $is_expired ) {
304
+ $response['publicWriteToken'] = $token;
305
+ $response['canWrite'] = true;
306
+ }
307
+
308
+ if ( $expires_at > 0 ) {
309
+ $response['publicWriteExpiresAt'] = $expires_at;
310
+ if ( $is_expired ) {
311
+ $response['canWrite'] = false;
312
+ }
313
+ }
314
+
315
+ return $response;
316
+ }
317
+
198
318
  // Route handlers are the main product-level extension point for parent-block request/response shaping.
199
319
  function {{phpPrefix}}_handle_get_state( WP_REST_Request $request ) {
200
320
  $payload = {{phpPrefix}}_validate_and_sanitize_request(
@@ -221,6 +341,33 @@ function {{phpPrefix}}_handle_get_state( WP_REST_Request $request ) {
221
341
  );
222
342
  }
223
343
 
344
+ function {{phpPrefix}}_handle_get_bootstrap( WP_REST_Request $request ) {
345
+ $payload = {{phpPrefix}}_validate_and_sanitize_request(
346
+ array(
347
+ 'postId' => $request->get_param( 'postId' ),
348
+ 'resourceKey' => $request->get_param( 'resourceKey' ),
349
+ ),
350
+ {{phpPrefix}}_get_rest_build_dir(),
351
+ 'bootstrap-query',
352
+ 'query'
353
+ );
354
+
355
+ if ( is_wp_error( $payload ) ) {
356
+ return $payload;
357
+ }
358
+
359
+ $response = rest_ensure_response(
360
+ {{phpPrefix}}_build_bootstrap_response(
361
+ (int) $payload['postId'],
362
+ (string) $payload['resourceKey']
363
+ )
364
+ );
365
+
366
+ $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0, s-maxage=0' );
367
+ $response->header( 'Pragma', 'no-cache' );
368
+ return $response;
369
+ }
370
+
224
371
  function {{phpPrefix}}_handle_write_state( WP_REST_Request $request ) {
225
372
  $payload = {{phpPrefix}}_validate_and_sanitize_request(
226
373
  $request->get_json_params(),
@@ -285,6 +432,18 @@ function {{phpPrefix}}_register_routes() {
285
432
  ),
286
433
  )
287
434
  );
435
+
436
+ register_rest_route(
437
+ '{{namespace}}/v1',
438
+ '/{{slugKebabCase}}/bootstrap',
439
+ array(
440
+ array(
441
+ 'methods' => WP_REST_Server::READABLE,
442
+ 'callback' => '{{phpPrefix}}_handle_get_bootstrap',
443
+ 'permission_callback' => '__return_true',
444
+ ),
445
+ )
446
+ );
288
447
  }
289
448
 
290
449
  function {{phpPrefix}}_register_blocks() {