@wp-typia/project-tools 0.13.4 → 0.14.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.
@@ -37,12 +37,12 @@ export function getTemplateSourceOfTruthNote(templateId, { compoundPersistenceEn
37
37
  if (templateId === "compound") {
38
38
  const compoundBase = "`src/blocks/*/types.ts` files remain the source of truth for each block's `block.json`, `typia.manifest.json`, and `typia-validator.php`. Fresh scaffolds include starter `typia.manifest.json` files so editor imports resolve before the first sync.";
39
39
  if (compoundPersistenceEnabled) {
40
- return `${compoundBase} For persistence-enabled parents, \`src/blocks/*/api-types.ts\` files remain the source of truth for \`src/blocks/*/api-schemas/*\` when you run \`sync-rest\`.`;
40
+ return `${compoundBase} For persistence-enabled parents, \`src/blocks/*/api-types.ts\` files remain the source of truth for \`src/blocks/*/api-schemas/*\` when you run \`sync-rest\`, while \`src/blocks/*/transport.ts\` is the first-class transport seam for editor and frontend requests.`;
41
41
  }
42
42
  return compoundBase;
43
43
  }
44
44
  if (templateId === "persistence") {
45
- return "`src/types.ts` remains the source of truth for `block.json`, `typia.manifest.json`, and `typia-validator.php`. Fresh scaffolds include a starter `typia.manifest.json` so editor imports resolve before the first sync. `src/api-types.ts` remains the source of truth for `src/api-schemas/*` when you run `sync-rest`. This scaffold is intentionally server-rendered: `src/render.php` is the canonical frontend entry, and `src/save.tsx` returns `null` so PHP can inject post context, storage-backed state, and write-policy bootstrap data before hydration.";
45
+ return "`src/types.ts` remains the source of truth for `block.json`, `typia.manifest.json`, and `typia-validator.php`. Fresh scaffolds include a starter `typia.manifest.json` so editor imports resolve before the first sync. `src/api-types.ts` remains the source of truth for `src/api-schemas/*` when you run `sync-rest`, while `src/transport.ts` is the first-class transport seam for editor and frontend requests. This scaffold is intentionally server-rendered: `src/render.php` is the canonical frontend entry, and `src/save.tsx` returns `null` so PHP can inject post context, storage-backed state, and write-policy bootstrap data before hydration.";
46
46
  }
47
47
  return "`src/types.ts` remains the source of truth for `block.json`, `typia.manifest.json`, and `typia-validator.php`. Fresh scaffolds include a starter `typia.manifest.json` so editor imports resolve before the first sync. The basic scaffold stays static by design: `src/render.php` is only an opt-in server placeholder, `src/save.tsx` remains the canonical frontend output, and the generated webpack config keeps the current `@wordpress/scripts` CommonJS baseline unless you intentionally add `render` to `block.json`.";
48
48
  }
@@ -61,13 +61,14 @@ ${formatRunScript(packageManager, "add-child", '--slug faq-item --title "FAQ Ite
61
61
 
62
62
  This scaffolds a new hidden child block type, updates \`scripts/block-config.ts\` and \`src/blocks/*/children.ts\`, and leaves the default seeded child template unchanged.`;
63
63
  }
64
- function formatPhpRestExtensionPointsSection({ apiTypesPath, extraNote, mainPhpPath, mainPhpScope, }) {
64
+ function formatPhpRestExtensionPointsSection({ apiTypesPath, extraNote, mainPhpPath, mainPhpScope, transportPath, }) {
65
65
  const schemaJsonGlob = apiTypesPath.replace(/api-types\.ts$/u, "api-schemas/*.schema.json");
66
66
  const perContractOpenApiGlob = apiTypesPath.replace(/api-types\.ts$/u, "api-schemas/*.openapi.json");
67
67
  const aggregateOpenApiPath = apiTypesPath.replace(/api-types\.ts$/u, "api.openapi.json");
68
68
  const lines = [
69
69
  `- Edit \`${mainPhpPath}\` when you need to ${mainPhpScope}.`,
70
70
  "- Edit `inc/rest-auth.php` or `inc/rest-public.php` when you need to customize write permissions or token/request-id/nonce checks for the selected policy.",
71
+ `- Edit \`${transportPath}\` when you need to switch between direct WordPress REST and a contract-compatible proxy or BFF without changing the endpoint contracts.`,
71
72
  `- Keep \`${apiTypesPath}\` as the source of truth for request and response contracts, then regenerate \`${schemaJsonGlob}\`, per-contract \`${perContractOpenApiGlob}\`, and \`${aggregateOpenApiPath}\` with \`sync-rest\`.`,
72
73
  "- Avoid hand-editing generated schema and OpenAPI artifacts unless you are debugging generated output; they are meant to be regenerated from TypeScript contracts.",
73
74
  ];
@@ -85,6 +86,7 @@ export function getPhpRestExtensionPointsSection(templateId, { compoundPersisten
85
86
  apiTypesPath: "src/api-types.ts",
86
87
  mainPhpPath: `${slug}.php`,
87
88
  mainPhpScope: "change storage helpers, route handlers, response shaping, or route registration",
89
+ transportPath: "src/transport.ts",
88
90
  });
89
91
  }
90
92
  if (templateId === "compound" && compoundPersistenceEnabled) {
@@ -93,6 +95,7 @@ export function getPhpRestExtensionPointsSection(templateId, { compoundPersisten
93
95
  extraNote: "The hidden child block does not own REST routes or storage.",
94
96
  mainPhpPath: `${slug}.php`,
95
97
  mainPhpScope: "change parent-block storage helpers, route handlers, response shaping, or route registration",
98
+ transportPath: `src/blocks/${slug}/transport.ts`,
96
99
  });
97
100
  }
98
101
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wp-typia/project-tools",
3
- "version": "0.13.4",
3
+ "version": "0.14.0",
4
4
  "description": "Project orchestration and programmatic tooling for wp-typia",
5
5
  "packageManager": "bun@1.3.11",
6
6
  "type": "module",
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  callEndpoint,
3
- resolveRestRouteUrl,
4
3
  } from '@wp-typia/rest';
5
4
 
6
5
  import {
@@ -11,58 +10,49 @@ import {
11
10
  get{{pascalCase}}StateEndpoint,
12
11
  write{{pascalCase}}StateEndpoint,
13
12
  } from './api-client';
14
-
15
- export function resolveRestNonce( fallback?: string ): string | undefined {
16
- if ( typeof fallback === 'string' && fallback.length > 0 ) {
17
- return fallback;
18
- }
19
-
20
- if ( typeof window === 'undefined' ) {
21
- return undefined;
22
- }
23
-
24
- const wpApiSettings = ( window as typeof window & {
25
- wpApiSettings?: { nonce?: string };
26
- } ).wpApiSettings;
27
-
28
- return typeof wpApiSettings?.nonce === 'string' && wpApiSettings.nonce.length > 0
29
- ? wpApiSettings.nonce
30
- : undefined;
31
- }
13
+ import {
14
+ resolveTransportCallOptions,
15
+ type PersistenceTransportOptions,
16
+ } from './transport';
32
17
 
33
18
  export const stateEndpoint = {
34
19
  ...get{{pascalCase}}StateEndpoint,
35
- buildRequestOptions: () => ( {
36
- url: resolveRestRouteUrl( get{{pascalCase}}StateEndpoint.path ),
37
- } ),
38
20
  };
39
21
 
40
22
  export const writeStateEndpoint = {
41
23
  ...write{{pascalCase}}StateEndpoint,
42
- buildRequestOptions: () => ( {
43
- url: resolveRestRouteUrl( write{{pascalCase}}StateEndpoint.path ),
44
- } ),
45
24
  };
46
25
 
47
26
  export function fetchState(
48
- request: {{pascalCase}}StateQuery
27
+ request: {{pascalCase}}StateQuery,
28
+ options: PersistenceTransportOptions = {}
49
29
  ) {
50
- return callEndpoint( stateEndpoint, request );
30
+ return callEndpoint(
31
+ stateEndpoint,
32
+ request,
33
+ resolveTransportCallOptions(
34
+ options.transportTarget ?? 'frontend',
35
+ 'read',
36
+ stateEndpoint,
37
+ request,
38
+ options
39
+ )
40
+ );
51
41
  }
52
42
 
53
43
  export function writeState(
54
44
  request: {{pascalCase}}WriteStateRequest,
55
- restNonce?: string
45
+ options: PersistenceTransportOptions = {}
56
46
  ) {
57
- const nonce = resolveRestNonce( restNonce );
58
-
59
- return callEndpoint( writeStateEndpoint, request, {
60
- requestOptions: nonce
61
- ? {
62
- headers: {
63
- 'X-WP-Nonce': nonce,
64
- },
65
- }
66
- : undefined,
67
- } );
47
+ return callEndpoint(
48
+ writeStateEndpoint,
49
+ request,
50
+ resolveTransportCallOptions(
51
+ options.transportTarget ?? 'frontend',
52
+ 'write',
53
+ writeStateEndpoint,
54
+ request,
55
+ options
56
+ )
57
+ );
68
58
  }
@@ -11,26 +11,13 @@ import type {
11
11
  {{pascalCase}}WriteStateRequest,
12
12
  } from './api-types';
13
13
  import {
14
- resolveRestNonce,
15
14
  stateEndpoint,
16
15
  writeStateEndpoint,
17
16
  } from './api';
18
-
19
- {{#isAuthenticatedPersistencePolicy}}
20
- function buildWriteCallOptions( restNonce?: string ) {
21
- const nonce = resolveRestNonce( restNonce );
22
-
23
- return nonce
24
- ? {
25
- requestOptions: {
26
- headers: {
27
- 'X-WP-Nonce': nonce,
28
- },
29
- },
30
- }
31
- : undefined;
32
- }
33
- {{/isAuthenticatedPersistencePolicy}}
17
+ import {
18
+ resolveTransportCallOptions,
19
+ type PersistenceTransportTarget,
20
+ } from './transport';
34
21
 
35
22
  interface WriteStateMutationContext< Context > {
36
23
  previous:
@@ -49,9 +36,8 @@ export interface Use{{pascalCase}}StateQueryOptions<
49
36
  >,
50
37
  'resolveCallOptions'
51
38
  > {
52
- {{#isAuthenticatedPersistencePolicy}}
53
39
  restNonce?: string;
54
- {{/isAuthenticatedPersistencePolicy}}
40
+ transportTarget?: PersistenceTransportTarget;
55
41
  }
56
42
 
57
43
  export interface UseWrite{{pascalCase}}StateMutationOptions<
@@ -62,7 +48,7 @@ export interface UseWrite{{pascalCase}}StateMutationOptions<
62
48
  {{pascalCase}}StateResponse,
63
49
  WriteStateMutationContext< Context >
64
50
  >,
65
- 'invalidate' | 'onError' | 'onMutate'{{#isAuthenticatedPersistencePolicy}} | 'resolveCallOptions'{{/isAuthenticatedPersistencePolicy}}
51
+ 'invalidate' | 'onError' | 'onMutate' | 'resolveCallOptions'
66
52
  > {
67
53
  onError?: (
68
54
  error: unknown,
@@ -74,9 +60,8 @@ export interface UseWrite{{pascalCase}}StateMutationOptions<
74
60
  request: {{pascalCase}}WriteStateRequest,
75
61
  client: import('@wp-typia/rest/react').EndpointDataClient
76
62
  ) => Context | Promise<Context>;
77
- {{#isAuthenticatedPersistencePolicy}}
78
63
  restNonce?: string;
79
- {{/isAuthenticatedPersistencePolicy}}
64
+ transportTarget?: PersistenceTransportTarget;
80
65
  }
81
66
 
82
67
  export function use{{pascalCase}}StateQuery<
@@ -85,20 +70,26 @@ export function use{{pascalCase}}StateQuery<
85
70
  request: {{pascalCase}}StateQuery,
86
71
  options: Use{{pascalCase}}StateQueryOptions< Selected > = {}
87
72
  ) {
88
- {{#isAuthenticatedPersistencePolicy}}
89
73
  const {
90
74
  restNonce,
75
+ transportTarget = 'editor',
91
76
  ...queryOptions
92
77
  } = options;
93
78
 
94
79
  return useEndpointQuery( stateEndpoint, request, {
95
80
  ...queryOptions,
96
- resolveCallOptions: () => buildWriteCallOptions( restNonce ),
81
+ resolveCallOptions: () =>
82
+ resolveTransportCallOptions(
83
+ transportTarget,
84
+ 'read',
85
+ stateEndpoint,
86
+ request,
87
+ {
88
+ restNonce,
89
+ transportTarget,
90
+ }
91
+ ),
97
92
  } );
98
- {{/isAuthenticatedPersistencePolicy}}
99
- {{^isAuthenticatedPersistencePolicy}}
100
- return useEndpointQuery( stateEndpoint, request, options );
101
- {{/isAuthenticatedPersistencePolicy}}
102
93
  }
103
94
 
104
95
  export function useWrite{{pascalCase}}StateMutation<
@@ -109,9 +100,8 @@ export function useWrite{{pascalCase}}StateMutation<
109
100
  const {
110
101
  onError,
111
102
  onMutate,
112
- {{#isAuthenticatedPersistencePolicy}}
113
103
  restNonce,
114
- {{/isAuthenticatedPersistencePolicy}}
104
+ transportTarget = 'editor',
115
105
  ...mutationOptions
116
106
  } = options;
117
107
 
@@ -185,8 +175,16 @@ export function useWrite{{pascalCase}}StateMutation<
185
175
  userContext,
186
176
  };
187
177
  },
188
- {{#isAuthenticatedPersistencePolicy}}
189
- resolveCallOptions: () => buildWriteCallOptions( restNonce ),
190
- {{/isAuthenticatedPersistencePolicy}}
178
+ resolveCallOptions: ( request ) =>
179
+ resolveTransportCallOptions(
180
+ transportTarget,
181
+ 'write',
182
+ writeStateEndpoint,
183
+ request,
184
+ {
185
+ restNonce,
186
+ transportTarget,
187
+ }
188
+ ),
191
189
  } );
192
190
  }
@@ -47,6 +47,9 @@ const { actions, state } = store( '{{slugKebabCase}}', {
47
47
  const result = await fetchState( {
48
48
  postId: context.postId,
49
49
  resourceKey: context.resourceKey,
50
+ }, {
51
+ restNonce: context.restNonce,
52
+ transportTarget: 'frontend',
50
53
  } );
51
54
  if ( ! result.isValid || ! result.data ) {
52
55
  state.error = result.errors[ 0 ]?.expected ?? 'Unable to load counter';
@@ -96,7 +99,10 @@ const { actions, state } = store( '{{slugKebabCase}}', {
96
99
  ? context.publicWriteToken
97
100
  : undefined,
98
101
  resourceKey: context.resourceKey,
99
- }, context.restNonce );
102
+ }, {
103
+ restNonce: context.restNonce,
104
+ transportTarget: 'frontend',
105
+ } );
100
106
  if ( ! result.isValid || ! result.data ) {
101
107
  state.error = result.errors[ 0 ]?.expected ?? 'Unable to update counter';
102
108
  return;
@@ -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
+ }
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  callEndpoint,
3
- resolveRestRouteUrl,
4
3
  } from '@wp-typia/rest';
5
4
 
6
5
  import {
@@ -11,58 +10,49 @@ import {
11
10
  get{{pascalCase}}StateEndpoint,
12
11
  write{{pascalCase}}StateEndpoint,
13
12
  } from './api-client';
14
-
15
- export function resolveRestNonce( fallback?: string ): string | undefined {
16
- if ( typeof fallback === 'string' && fallback.length > 0 ) {
17
- return fallback;
18
- }
19
-
20
- if ( typeof window === 'undefined' ) {
21
- return undefined;
22
- }
23
-
24
- const wpApiSettings = ( window as typeof window & {
25
- wpApiSettings?: { nonce?: string };
26
- } ).wpApiSettings;
27
-
28
- return typeof wpApiSettings?.nonce === 'string' && wpApiSettings.nonce.length > 0
29
- ? wpApiSettings.nonce
30
- : undefined;
31
- }
13
+ import {
14
+ resolveTransportCallOptions,
15
+ type PersistenceTransportOptions,
16
+ } from './transport';
32
17
 
33
18
  export const stateEndpoint = {
34
19
  ...get{{pascalCase}}StateEndpoint,
35
- buildRequestOptions: () => ( {
36
- url: resolveRestRouteUrl( get{{pascalCase}}StateEndpoint.path ),
37
- } ),
38
20
  };
39
21
 
40
22
  export const writeStateEndpoint = {
41
23
  ...write{{pascalCase}}StateEndpoint,
42
- buildRequestOptions: () => ( {
43
- url: resolveRestRouteUrl( write{{pascalCase}}StateEndpoint.path ),
44
- } ),
45
24
  };
46
25
 
47
26
  export function fetchState(
48
- request: {{pascalCase}}StateQuery
27
+ request: {{pascalCase}}StateQuery,
28
+ options: PersistenceTransportOptions = {}
49
29
  ) {
50
- return callEndpoint( stateEndpoint, request );
30
+ return callEndpoint(
31
+ stateEndpoint,
32
+ request,
33
+ resolveTransportCallOptions(
34
+ options.transportTarget ?? 'frontend',
35
+ 'read',
36
+ stateEndpoint,
37
+ request,
38
+ options
39
+ )
40
+ );
51
41
  }
52
42
 
53
43
  export function writeState(
54
44
  request: {{pascalCase}}WriteStateRequest,
55
- restNonce?: string
45
+ options: PersistenceTransportOptions = {}
56
46
  ) {
57
- const nonce = resolveRestNonce( restNonce );
58
-
59
- return callEndpoint( writeStateEndpoint, request, {
60
- requestOptions: nonce
61
- ? {
62
- headers: {
63
- 'X-WP-Nonce': nonce,
64
- },
65
- }
66
- : undefined,
67
- } );
47
+ return callEndpoint(
48
+ writeStateEndpoint,
49
+ request,
50
+ resolveTransportCallOptions(
51
+ options.transportTarget ?? 'frontend',
52
+ 'write',
53
+ writeStateEndpoint,
54
+ request,
55
+ options
56
+ )
57
+ );
68
58
  }
@@ -11,26 +11,13 @@ import type {
11
11
  {{pascalCase}}WriteStateRequest,
12
12
  } from './api-types';
13
13
  import {
14
- resolveRestNonce,
15
14
  stateEndpoint,
16
15
  writeStateEndpoint,
17
16
  } from './api';
18
-
19
- {{#isAuthenticatedPersistencePolicy}}
20
- function buildWriteCallOptions( restNonce?: string ) {
21
- const nonce = resolveRestNonce( restNonce );
22
-
23
- return nonce
24
- ? {
25
- requestOptions: {
26
- headers: {
27
- 'X-WP-Nonce': nonce,
28
- },
29
- },
30
- }
31
- : undefined;
32
- }
33
- {{/isAuthenticatedPersistencePolicy}}
17
+ import {
18
+ resolveTransportCallOptions,
19
+ type PersistenceTransportTarget,
20
+ } from './transport';
34
21
 
35
22
  interface WriteStateMutationContext< Context > {
36
23
  previous:
@@ -49,9 +36,8 @@ export interface Use{{pascalCase}}StateQueryOptions<
49
36
  >,
50
37
  'resolveCallOptions'
51
38
  > {
52
- {{#isAuthenticatedPersistencePolicy}}
53
39
  restNonce?: string;
54
- {{/isAuthenticatedPersistencePolicy}}
40
+ transportTarget?: PersistenceTransportTarget;
55
41
  }
56
42
 
57
43
  export interface UseWrite{{pascalCase}}StateMutationOptions<
@@ -62,7 +48,7 @@ export interface UseWrite{{pascalCase}}StateMutationOptions<
62
48
  {{pascalCase}}StateResponse,
63
49
  WriteStateMutationContext< Context >
64
50
  >,
65
- 'invalidate' | 'onError' | 'onMutate'{{#isAuthenticatedPersistencePolicy}} | 'resolveCallOptions'{{/isAuthenticatedPersistencePolicy}}
51
+ 'invalidate' | 'onError' | 'onMutate' | 'resolveCallOptions'
66
52
  > {
67
53
  onError?: (
68
54
  error: unknown,
@@ -74,9 +60,8 @@ export interface UseWrite{{pascalCase}}StateMutationOptions<
74
60
  request: {{pascalCase}}WriteStateRequest,
75
61
  client: import('@wp-typia/rest/react').EndpointDataClient
76
62
  ) => Context | Promise<Context>;
77
- {{#isAuthenticatedPersistencePolicy}}
78
63
  restNonce?: string;
79
- {{/isAuthenticatedPersistencePolicy}}
64
+ transportTarget?: PersistenceTransportTarget;
80
65
  }
81
66
 
82
67
  export function use{{pascalCase}}StateQuery<
@@ -85,20 +70,26 @@ export function use{{pascalCase}}StateQuery<
85
70
  request: {{pascalCase}}StateQuery,
86
71
  options: Use{{pascalCase}}StateQueryOptions< Selected > = {}
87
72
  ) {
88
- {{#isAuthenticatedPersistencePolicy}}
89
73
  const {
90
74
  restNonce,
75
+ transportTarget = 'editor',
91
76
  ...queryOptions
92
77
  } = options;
93
78
 
94
79
  return useEndpointQuery( stateEndpoint, request, {
95
80
  ...queryOptions,
96
- resolveCallOptions: () => buildWriteCallOptions( restNonce ),
81
+ resolveCallOptions: () =>
82
+ resolveTransportCallOptions(
83
+ transportTarget,
84
+ 'read',
85
+ stateEndpoint,
86
+ request,
87
+ {
88
+ restNonce,
89
+ transportTarget,
90
+ }
91
+ ),
97
92
  } );
98
- {{/isAuthenticatedPersistencePolicy}}
99
- {{^isAuthenticatedPersistencePolicy}}
100
- return useEndpointQuery( stateEndpoint, request, options );
101
- {{/isAuthenticatedPersistencePolicy}}
102
93
  }
103
94
 
104
95
  export function useWrite{{pascalCase}}StateMutation<
@@ -109,9 +100,8 @@ export function useWrite{{pascalCase}}StateMutation<
109
100
  const {
110
101
  onError,
111
102
  onMutate,
112
- {{#isAuthenticatedPersistencePolicy}}
113
103
  restNonce,
114
- {{/isAuthenticatedPersistencePolicy}}
104
+ transportTarget = 'editor',
115
105
  ...mutationOptions
116
106
  } = options;
117
107
 
@@ -185,8 +175,16 @@ export function useWrite{{pascalCase}}StateMutation<
185
175
  userContext,
186
176
  };
187
177
  },
188
- {{#isAuthenticatedPersistencePolicy}}
189
- resolveCallOptions: () => buildWriteCallOptions( restNonce ),
190
- {{/isAuthenticatedPersistencePolicy}}
178
+ resolveCallOptions: ( request ) =>
179
+ resolveTransportCallOptions(
180
+ transportTarget,
181
+ 'write',
182
+ writeStateEndpoint,
183
+ request,
184
+ {
185
+ restNonce,
186
+ transportTarget,
187
+ }
188
+ ),
191
189
  } );
192
190
  }
@@ -48,6 +48,9 @@ const { actions, state } = store( '{{slugKebabCase}}', {
48
48
  const result = await fetchState( {
49
49
  postId: context.postId,
50
50
  resourceKey: context.resourceKey,
51
+ }, {
52
+ restNonce: context.restNonce,
53
+ transportTarget: 'frontend',
51
54
  } );
52
55
  if ( ! result.isValid || ! result.data ) {
53
56
  state.error = result.errors[ 0 ]?.expected ?? 'Unable to load counter';
@@ -97,7 +100,10 @@ const { actions, state } = store( '{{slugKebabCase}}', {
97
100
  ? context.publicWriteToken
98
101
  : undefined,
99
102
  resourceKey: context.resourceKey,
100
- }, context.restNonce );
103
+ }, {
104
+ restNonce: context.restNonce,
105
+ transportTarget: 'frontend',
106
+ } );
101
107
  if ( ! result.isValid || ! result.data ) {
102
108
  state.error = result.errors[ 0 ]?.expected ?? 'Unable to update counter';
103
109
  return;
@@ -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
+ }