@wp-typia/create-workspace-template 0.17.0 → 0.17.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.
package/README.md CHANGED
@@ -6,20 +6,96 @@ Use it through the canonical CLI:
6
6
 
7
7
  ```bash
8
8
  npx wp-typia create my-plugin --template @wp-typia/create-workspace-template
9
+ npx wp-typia create my-plugin --template workspace --profile plugin-qa
9
10
  ```
10
11
 
11
12
  The generated project starts with an empty `src/blocks/*` workspace shell and is designed to grow through:
12
13
 
13
14
  ```bash
14
- wp-typia add block my-block --template basic
15
- wp-typia add integration-env local-smoke --wp-env --service docker-compose
16
- wp-typia add style callout-emphasis --block my-block
17
- wp-typia add transform quote-to-block --from core/quote --to my-block
18
- wp-typia add binding-source hero-data
19
- wp-typia add binding-source hero-data --block my-block --attribute headline
20
- wp-typia add contract external-retrieve-response --type ExternalRetrieveResponse
21
- wp-typia add post-meta integration-state --post-type post --type IntegrationStateMeta
22
- wp-typia add editor-plugin review-workflow --slot sidebar
23
- wp-typia add editor-plugin seo-notes --slot document-setting-panel
24
- wp-typia add hooked-block my-block --anchor core/post-content --position after
15
+ npm run wp-typia:add -- block my-block --template basic
16
+ npm run wp-typia:add -- integration-env local-smoke --wp-env --release-zip --service docker-compose
17
+ npm run wp-typia:add -- style callout-emphasis --block my-block
18
+ npm run wp-typia:add -- transform quote-to-block --from core/quote --to my-block
19
+ npm run wp-typia:add -- binding-source hero-data
20
+ npm run wp-typia:add -- binding-source hero-data --block my-block --attribute headline
21
+ npm run wp-typia:add -- contract external-retrieve-response --type ExternalRetrieveResponse
22
+ npm run wp-typia:add -- rest-resource external-record --manual --namespace legacy/v1 --route-pattern '/records/(?P<post_id>[\d]+)' --permission-callback legacy_can_read_records --controller-class Legacy\\Records\\Controller
23
+ npm run wp-typia:add -- post-meta integration-state --post-type post --type IntegrationStateMeta
24
+ npm run wp-typia:add -- editor-plugin review-workflow --slot sidebar
25
+ npm run wp-typia:add -- editor-plugin seo-notes --slot document-setting-panel
26
+ npm run wp-typia:add -- hooked-block my-block --anchor core/post-content --position after
25
27
  ```
28
+
29
+ ## CLI binary policy
30
+
31
+ Official workspace projects install `wp-typia` as a local devDependency and
32
+ expose package scripts for the common CLI entrypoints:
33
+
34
+ ```json
35
+ {
36
+ "scripts": {
37
+ "sync-rest:package": "tsx scripts/sync-rest-contracts.ts --package",
38
+ "sync-rest:package:check": "tsx scripts/sync-rest-contracts.ts --package --check",
39
+ "wp-typia:sync": "wp-typia sync",
40
+ "wp-typia:doctor": "wp-typia doctor",
41
+ "wp-typia:doctor:workspace": "wp-typia doctor --workspace-only",
42
+ "wp-typia:add": "wp-typia add"
43
+ },
44
+ "devDependencies": {
45
+ "wp-typia": "<version>"
46
+ }
47
+ }
48
+ ```
49
+
50
+ ## Minimal Workspace vs Plugin QA Profile
51
+
52
+ Use the default workspace template when you want the smallest editable shell and
53
+ plan to add QA infrastructure later. Use `--profile plugin-qa` when the plugin
54
+ should start with local WordPress smoke checks, `.wp-env.json`, `.env.example`,
55
+ `scripts/integration-smoke/local-smoke.mjs`, and release zip scripts.
56
+
57
+ Existing minimal workspaces can adopt the same QA surface later:
58
+
59
+ ```bash
60
+ npm run wp-typia:add -- integration-env local-smoke --wp-env --release-zip
61
+ ```
62
+
63
+ Use the script form from generated workspaces so the executable CLI package is
64
+ clearly separated from support packages such as `@wp-typia/project-tools`.
65
+
66
+ Workspaces that ship REST resources can run `sync-rest:package` before release
67
+ builds to copy generated runtime schemas into `inc/rest-schemas`. Add
68
+ `sync-rest:package:check` to release CI so stale or missing packaged schemas are
69
+ caught before a zip is built without TypeScript source files.
70
+
71
+ The generated `inc/rest-schema.php` helper centralizes schema loading and
72
+ WordPress REST sanitization. Generated REST resources use it automatically, and
73
+ custom PHP resources can call
74
+ `<phpPrefix>_get_wordpress_rest_schema( 'settings-response', array( 'resource' => 'settings' ) )`
75
+ or `<phpPrefix>_validate_and_sanitize_rest_payload(...)` to reuse the same
76
+ packaged/source lookup and `WP_Error` handling.
77
+
78
+ Use generated REST resources when the workspace should own the PHP route glue.
79
+ Use `wp-typia add rest-resource <name> --manual` for provider routes owned by
80
+ another plugin, editor contract, or legacy controller. Manual routes can declare
81
+ custom namespaces, `--path` or `--route-pattern` patterns with named captures,
82
+ and route-owner metadata such as `--permission-callback`,
83
+ `--controller-class`, and `--controller-extends` while still generating typed
84
+ schemas, OpenAPI, clients, and drift checks.
85
+
86
+ Manual settings contracts can model write-only credentials with
87
+ `--secret-field <field>`. The generated request type marks the field as
88
+ `tags.Secret<"has<Field>">` plus `tags.PreserveOnEmpty<true>`, response types
89
+ expose only the safe has-value field, and generated admin settings screens omit
90
+ blank secrets so existing stored values are preserved by default.
91
+
92
+ | Package manager | Doctor | Sync check | Add a block |
93
+ | --------------- | -------------------------- | ---------------------------------- | --------------------------------------------------------- |
94
+ | npm | `npm run wp-typia:doctor` | `npm run wp-typia:sync -- --check` | `npm run wp-typia:add -- block my-block --template basic` |
95
+ | pnpm | `pnpm run wp-typia:doctor` | `pnpm run wp-typia:sync --check` | `pnpm run wp-typia:add block my-block --template basic` |
96
+ | bun | `bun run wp-typia:doctor` | `bun run wp-typia:sync --check` | `bun run wp-typia:add block my-block --template basic` |
97
+ | yarn | `yarn run wp-typia:doctor` | `yarn run wp-typia:sync --check` | `yarn run wp-typia:add block my-block --template basic` |
98
+
99
+ Existing workspaces can adopt the same scripts without regenerating. Pin
100
+ `wp-typia` to the project version you want CI to run, then use
101
+ `wp-typia:doctor:workspace` when optional local runtimes should be advisory.
@@ -7,31 +7,84 @@
7
7
  This scaffold is the official empty `wp-typia` workspace shell. It starts with
8
8
  an empty `src/blocks/*` inventory and grows through `wp-typia add`.
9
9
 
10
+ Use the minimal workspace when you want the smallest editable shell and plan to
11
+ add QA infrastructure later. Scaffold with `--profile plugin-qa` when the plugin
12
+ should start with local WordPress smoke checks, `.wp-env.json`, `.env.example`,
13
+ `scripts/integration-smoke/local-smoke.mjs`, and release zip scripts. Existing
14
+ minimal workspaces can adopt the same surface later:
15
+
16
+ ```bash
17
+ bun run wp-typia:add integration-env local-smoke --wp-env --release-zip
18
+ ```
19
+
10
20
  ## First Block
11
21
 
12
22
  ```bash
13
- wp-typia add block counter-card --template basic
23
+ bun run wp-typia:add block counter-card --template basic
14
24
  ```
15
25
 
16
26
  Persistence-ready blocks and compound parent/child blocks can be added later:
17
27
 
18
28
  ```bash
19
- wp-typia add block counter-card --template persistence --data-storage custom-table --persistence-policy authenticated
20
- wp-typia add block faq-stack --template compound --data-storage custom-table --persistence-policy public
29
+ bun run wp-typia:add block counter-card --template persistence --data-storage custom-table --persistence-policy authenticated
30
+ bun run wp-typia:add block faq-stack --template compound --data-storage custom-table --persistence-policy public
21
31
  ```
22
32
 
23
33
  Variations, block styles, transforms, patterns, REST resources, and post meta
24
34
  contracts can also be added after the first block exists:
25
35
 
26
36
  ```bash
27
- wp-typia add variation hero-card --block counter-card
28
- wp-typia add style callout-emphasis --block counter-card
29
- wp-typia add transform quote-to-counter --from core/quote --to counter-card
30
- wp-typia add pattern hero-layout
31
- wp-typia add rest-resource snapshots --namespace {{namespace}}/v1 --methods list,read,create
32
- wp-typia add post-meta integration-state --post-type post
37
+ bun run wp-typia:add variation hero-card --block counter-card
38
+ bun run wp-typia:add style callout-emphasis --block counter-card
39
+ bun run wp-typia:add transform quote-to-counter --from core/quote --to counter-card
40
+ bun run wp-typia:add pattern hero-layout
41
+ bun run wp-typia:add rest-resource snapshots --namespace {{namespace}}/v1 --methods list,read,create
42
+ bun run wp-typia:add rest-resource external-record --manual --namespace legacy/v1 --route-pattern '/records/(?P<post_id>[\d]+)' --permission-callback legacy_can_read_records --controller-class Legacy\\Records\\Controller
43
+ bun run wp-typia:add post-meta integration-state --post-type post
44
+ ```
45
+
46
+ ## CLI Commands
47
+
48
+ This workspace installs the executable `wp-typia` CLI as a local devDependency
49
+ and exposes stable package scripts so support packages are not confused with the
50
+ binary package:
51
+
52
+ ```bash
53
+ bun run wp-typia:doctor
54
+ bun run wp-typia:doctor:workspace
55
+ bun run wp-typia:sync --check
56
+ bun run sync-rest:package:check
57
+ bun run wp-typia:add block counter-card --template basic
33
58
  ```
34
59
 
60
+ Use `wp-typia:doctor:workspace` in CI when optional local runtimes are advisory
61
+ but workspace checks must still fail the job.
62
+
63
+ Run `sync-rest:package` before building release zips when REST resources are
64
+ registered. It copies generated runtime schemas into `inc/rest-schemas` and
65
+ `sync-rest:package:check` fails CI if packaged schemas are missing or stale.
66
+
67
+ The generated `inc/rest-schema.php` helper centralizes schema loading and
68
+ WordPress REST sanitization. Generated REST resources use it automatically, and
69
+ custom PHP resources can call
70
+ `{{phpPrefix}}_get_wordpress_rest_schema( 'settings-response', array( 'resource' => 'settings' ) )`
71
+ or `{{phpPrefix}}_validate_and_sanitize_rest_payload(...)` to reuse the same
72
+ packaged/source lookup and `WP_Error` handling.
73
+
74
+ Use generated REST resources when the workspace should own the PHP route glue.
75
+ Use `wp-typia add rest-resource <name> --manual` for provider routes owned by
76
+ another plugin, editor contract, or legacy controller. Manual routes can declare
77
+ custom namespaces, `--path` or `--route-pattern` patterns with named captures,
78
+ and route-owner metadata such as `--permission-callback`,
79
+ `--controller-class`, and `--controller-extends` while still generating typed
80
+ schemas, OpenAPI, clients, and drift checks.
81
+
82
+ Manual settings contracts can model write-only credentials with
83
+ `--secret-field <field>`. The generated request type marks the field as
84
+ `tags.Secret<"has<Field>">` plus `tags.PreserveOnEmpty<true>`, response types
85
+ expose only the safe has-value field, and generated admin settings screens omit
86
+ blank secrets so existing stored values are preserved by default.
87
+
35
88
  ## Development
36
89
 
37
90
  ```bash
@@ -0,0 +1,184 @@
1
+ <?php
2
+ if ( ! defined( 'ABSPATH' ) ) {
3
+ return;
4
+ }
5
+
6
+ if ( ! function_exists( '{{phpPrefix}}_is_valid_rest_schema_key' ) ) {
7
+ function {{phpPrefix}}_is_valid_rest_schema_key( $value ) {
8
+ return is_string( $value ) && 1 === preg_match( '/\A[A-Za-z0-9_-]+\z/', $value );
9
+ }
10
+ }
11
+
12
+ if ( ! function_exists( '{{phpPrefix}}_is_valid_rest_resource_slug' ) ) {
13
+ function {{phpPrefix}}_is_valid_rest_resource_slug( $value ) {
14
+ return is_string( $value ) && 1 === preg_match( '/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/', $value );
15
+ }
16
+ }
17
+
18
+ if ( ! function_exists( '{{phpPrefix}}_resolve_rest_schema_paths' ) ) {
19
+ function {{phpPrefix}}_resolve_rest_schema_paths( $schema_name, $options = array() ) {
20
+ if ( ! {{phpPrefix}}_is_valid_rest_schema_key( $schema_name ) ) {
21
+ return new WP_Error(
22
+ 'invalid_rest_schema_name',
23
+ 'Invalid REST schema name.',
24
+ array( 'status' => 500 )
25
+ );
26
+ }
27
+
28
+ $options = is_array( $options ) ? $options : array();
29
+ $project_root = dirname( __DIR__ );
30
+ $paths = array();
31
+
32
+ if ( isset( $options['resource'] ) && '' !== $options['resource'] ) {
33
+ if ( ! {{phpPrefix}}_is_valid_rest_resource_slug( $options['resource'] ) ) {
34
+ return new WP_Error(
35
+ 'invalid_rest_schema_resource',
36
+ 'Invalid REST schema resource slug.',
37
+ array( 'status' => 500 )
38
+ );
39
+ }
40
+
41
+ $resource_slug = $options['resource'];
42
+ $paths[] = __DIR__ . '/rest-schemas/rest/' . $resource_slug . '/' . $schema_name . '.schema.json';
43
+ $paths[] = $project_root . '/src/rest/' . $resource_slug . '/api-schemas/' . $schema_name . '.schema.json';
44
+ }
45
+
46
+ if ( isset( $options['paths'] ) && is_array( $options['paths'] ) ) {
47
+ foreach ( $options['paths'] as $schema_path ) {
48
+ if ( is_string( $schema_path ) && '' !== $schema_path && ! in_array( $schema_path, $paths, true ) ) {
49
+ $paths[] = $schema_path;
50
+ }
51
+ }
52
+ }
53
+
54
+ return $paths;
55
+ }
56
+ }
57
+
58
+ if ( ! function_exists( '{{phpPrefix}}_load_rest_schema' ) ) {
59
+ function {{phpPrefix}}_load_rest_schema( $schema_name, $options = array() ) {
60
+ $schema_paths = {{phpPrefix}}_resolve_rest_schema_paths( $schema_name, $options );
61
+ if ( is_wp_error( $schema_paths ) ) {
62
+ return $schema_paths;
63
+ }
64
+
65
+ foreach ( $schema_paths as $schema_path ) {
66
+ if ( ! is_file( $schema_path ) ) {
67
+ continue;
68
+ }
69
+
70
+ if ( ! is_readable( $schema_path ) ) {
71
+ return new WP_Error(
72
+ 'unreadable_rest_schema',
73
+ 'Generated REST schema is not readable.',
74
+ array(
75
+ 'path' => $schema_path,
76
+ 'status' => 500,
77
+ )
78
+ );
79
+ }
80
+
81
+ $schema_json = file_get_contents( $schema_path );
82
+ if ( false === $schema_json ) {
83
+ return new WP_Error(
84
+ 'rest_schema_read_failed',
85
+ 'Generated REST schema could not be read.',
86
+ array(
87
+ 'path' => $schema_path,
88
+ 'status' => 500,
89
+ )
90
+ );
91
+ }
92
+
93
+ $decoded = json_decode( $schema_json, true );
94
+ if ( ! is_array( $decoded ) ) {
95
+ return new WP_Error(
96
+ 'malformed_rest_schema',
97
+ 'Generated REST schema contains malformed JSON.',
98
+ array(
99
+ 'json_error' => json_last_error_msg(),
100
+ 'path' => $schema_path,
101
+ 'status' => 500,
102
+ )
103
+ );
104
+ }
105
+
106
+ return $decoded;
107
+ }
108
+
109
+ return new WP_Error(
110
+ 'missing_rest_schema',
111
+ 'Generated REST schema could not be found.',
112
+ array(
113
+ 'paths' => $schema_paths,
114
+ 'schema' => $schema_name,
115
+ 'status' => 500,
116
+ )
117
+ );
118
+ }
119
+ }
120
+
121
+ if ( ! function_exists( '{{phpPrefix}}_prepare_rest_schema_for_wordpress' ) ) {
122
+ function {{phpPrefix}}_prepare_rest_schema_for_wordpress( $schema ) {
123
+ if ( ! is_array( $schema ) ) {
124
+ return $schema;
125
+ }
126
+
127
+ unset( $schema['$schema'], $schema['title'] );
128
+
129
+ foreach ( array( 'properties', 'patternProperties', 'definitions', '$defs' ) as $schema_map_key ) {
130
+ if ( ! isset( $schema[ $schema_map_key ] ) || ! is_array( $schema[ $schema_map_key ] ) ) {
131
+ continue;
132
+ }
133
+
134
+ foreach ( $schema[ $schema_map_key ] as $key => $property_schema ) {
135
+ $schema[ $schema_map_key ][ $key ] = {{phpPrefix}}_prepare_rest_schema_for_wordpress( $property_schema );
136
+ }
137
+ }
138
+
139
+ foreach ( array( 'items', 'additionalProperties', 'contains', 'propertyNames', 'not', 'if', 'then', 'else' ) as $nested_schema_key ) {
140
+ if ( isset( $schema[ $nested_schema_key ] ) && is_array( $schema[ $nested_schema_key ] ) ) {
141
+ $schema[ $nested_schema_key ] = {{phpPrefix}}_prepare_rest_schema_for_wordpress( $schema[ $nested_schema_key ] );
142
+ }
143
+ }
144
+
145
+ foreach ( array( 'allOf', 'anyOf', 'oneOf' ) as $schema_list_key ) {
146
+ if ( ! isset( $schema[ $schema_list_key ] ) || ! is_array( $schema[ $schema_list_key ] ) ) {
147
+ continue;
148
+ }
149
+
150
+ foreach ( $schema[ $schema_list_key ] as $index => $variant_schema ) {
151
+ $schema[ $schema_list_key ][ $index ] = {{phpPrefix}}_prepare_rest_schema_for_wordpress( $variant_schema );
152
+ }
153
+ }
154
+
155
+ return $schema;
156
+ }
157
+ }
158
+
159
+ if ( ! function_exists( '{{phpPrefix}}_get_wordpress_rest_schema' ) ) {
160
+ function {{phpPrefix}}_get_wordpress_rest_schema( $schema_name, $options = array() ) {
161
+ $schema = {{phpPrefix}}_load_rest_schema( $schema_name, $options );
162
+ if ( is_wp_error( $schema ) ) {
163
+ return $schema;
164
+ }
165
+
166
+ return {{phpPrefix}}_prepare_rest_schema_for_wordpress( $schema );
167
+ }
168
+ }
169
+
170
+ if ( ! function_exists( '{{phpPrefix}}_validate_and_sanitize_rest_payload' ) ) {
171
+ function {{phpPrefix}}_validate_and_sanitize_rest_payload( $value, $schema_name, $param_name, $options = array() ) {
172
+ $rest_schema = {{phpPrefix}}_get_wordpress_rest_schema( $schema_name, $options );
173
+ if ( is_wp_error( $rest_schema ) ) {
174
+ return $rest_schema;
175
+ }
176
+
177
+ $validation = rest_validate_value_from_schema( $value, $rest_schema, $param_name );
178
+ if ( is_wp_error( $validation ) ) {
179
+ return $validation;
180
+ }
181
+
182
+ return rest_sanitize_value_from_schema( $value, $rest_schema, $param_name );
183
+ }
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wp-typia/create-workspace-template",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Official empty wp-typia workspace template package",
5
5
  "packageManager": "bun@1.3.11",
6
6
  "type": "module",
@@ -8,6 +8,7 @@
8
8
  "package.json.mustache",
9
9
  "tsconfig.json.mustache",
10
10
  "webpack.config.js.mustache",
11
+ "inc/",
11
12
  "scripts/",
12
13
  "src/",
13
14
  "languages/",
@@ -9,6 +9,12 @@
9
9
  "sync": "tsx scripts/sync-project.ts",
10
10
  "sync-types": "tsx scripts/sync-types-to-block-json.ts",
11
11
  "sync-rest": "tsx scripts/sync-rest-contracts.ts",
12
+ "sync-rest:package": "tsx scripts/sync-rest-contracts.ts --package",
13
+ "sync-rest:package:check": "tsx scripts/sync-rest-contracts.ts --package --check",
14
+ "wp-typia:sync": "wp-typia sync",
15
+ "wp-typia:doctor": "wp-typia doctor",
16
+ "wp-typia:doctor:workspace": "wp-typia doctor --workspace-only",
17
+ "wp-typia:add": "wp-typia add",
12
18
  "build": "bun run sync --check && node scripts/build-workspace.mjs build",
13
19
  "start": "bun run sync && node scripts/build-workspace.mjs start",
14
20
  "dev": "bun run start",
@@ -31,6 +37,7 @@
31
37
  "@wp-typia/block-types": "{{blockTypesPackageVersion}}",
32
38
  "@wp-typia/project-tools": "{{projectToolsPackageVersion}}",
33
39
  "@wp-typia/rest": "{{restPackageVersion}}",
40
+ "wp-typia": "{{wpTypiaPackageVersion}}",
34
41
  "@typia/unplugin": "^12.0.1",
35
42
  "@types/node": "^24.5.2",
36
43
  "@types/wordpress__block-editor": "^11.5.17",
@@ -63,6 +63,7 @@ export interface WorkspaceRestResourceBaseConfig {
63
63
  typeof import( '@wp-typia/block-runtime/metadata-core' ).defineEndpointManifest
64
64
  >;
65
65
  secretFieldName?: string;
66
+ secretPreserveOnEmpty?: boolean;
66
67
  secretStateFieldName?: string;
67
68
  slug: string;
68
69
  typesFile: string;
@@ -114,6 +115,11 @@ export interface WorkspaceAdminViewConfig {
114
115
  source?: string;
115
116
  }
116
117
 
118
+ export interface WorkspaceRestSchemaPackageConfig {
119
+ manifestFile?: string;
120
+ outputDir: string;
121
+ }
122
+
117
123
  export const BLOCKS: WorkspaceBlockConfig[] = [
118
124
  // wp-typia add block entries
119
125
  ];
@@ -157,3 +163,8 @@ export const EDITOR_PLUGINS: WorkspaceEditorPluginConfig[] = [
157
163
  export const ADMIN_VIEWS: WorkspaceAdminViewConfig[] = [
158
164
  // wp-typia add admin-view entries
159
165
  ];
166
+
167
+ export const REST_SCHEMA_PACKAGE: WorkspaceRestSchemaPackageConfig = {
168
+ manifestFile: 'inc/rest-schemas/manifest.json',
169
+ outputDir: 'inc/rest-schemas',
170
+ };
@@ -1,4 +1,5 @@
1
1
  /* eslint-disable no-console */
2
+ import { promises as fsp } from 'node:fs';
2
3
  import path from 'node:path';
3
4
 
4
5
  import {
@@ -8,6 +9,7 @@ import {
8
9
  syncTypeSchemas,
9
10
  } from '@wp-typia/block-runtime/metadata-core';
10
11
 
12
+ import * as workspaceConfig from './block-config';
11
13
  import {
12
14
  BLOCKS,
13
15
  CONTRACTS,
@@ -19,9 +21,25 @@ import {
19
21
  type WorkspaceRestResourceConfig,
20
22
  } from './block-config';
21
23
 
24
+ type WorkspaceRestSchemaPackageConfig = {
25
+ manifestFile?: string;
26
+ outputDir: string;
27
+ };
28
+
29
+ const DEFAULT_REST_SCHEMA_PACKAGE: WorkspaceRestSchemaPackageConfig = {
30
+ manifestFile: 'inc/rest-schemas/manifest.json',
31
+ outputDir: 'inc/rest-schemas',
32
+ };
33
+ const workspaceConfigWithPackage = workspaceConfig as {
34
+ REST_SCHEMA_PACKAGE?: WorkspaceRestSchemaPackageConfig;
35
+ };
36
+ const REST_SCHEMA_PACKAGE =
37
+ workspaceConfigWithPackage.REST_SCHEMA_PACKAGE ?? DEFAULT_REST_SCHEMA_PACKAGE;
38
+
22
39
  function parseCliOptions( argv: string[] ) {
23
40
  const options = {
24
41
  check: false,
42
+ packageSchemas: false,
25
43
  };
26
44
 
27
45
  for ( const argument of argv ) {
@@ -30,12 +48,110 @@ function parseCliOptions( argv: string[] ) {
30
48
  continue;
31
49
  }
32
50
 
51
+ if ( argument === '--package' ) {
52
+ options.packageSchemas = true;
53
+ continue;
54
+ }
55
+
33
56
  throw new Error( `Unknown sync-rest flag: ${ argument }` );
34
57
  }
35
58
 
36
59
  return options;
37
60
  }
38
61
 
62
+ type RuntimeRestSchemaPackageEntry = {
63
+ id: string;
64
+ kind: 'block' | 'rest-resource';
65
+ owner: string;
66
+ path: string;
67
+ schema: string;
68
+ source: string;
69
+ };
70
+
71
+ type RuntimeRestSchemaPackageManifest = {
72
+ generatedBy: 'wp-typia sync-rest --package';
73
+ outputDir: string;
74
+ schemaVersion: 1;
75
+ schemas: RuntimeRestSchemaPackageEntry[];
76
+ };
77
+
78
+ function getNodeErrorCode( error: unknown ) {
79
+ return typeof error === 'object' &&
80
+ error !== null &&
81
+ 'code' in error &&
82
+ typeof ( error as { code?: unknown } ).code === 'string'
83
+ ? ( error as { code: string } ).code
84
+ : undefined;
85
+ }
86
+
87
+ function normalizeProjectPath( filePath: string, label: string ) {
88
+ if ( path.isAbsolute( filePath ) ) {
89
+ throw new Error( `${ label } must be project-relative: ${ filePath }` );
90
+ }
91
+
92
+ const normalized = path.normalize( filePath );
93
+ if ( normalized === '..' || normalized.startsWith( `..${ path.sep }` ) ) {
94
+ throw new Error( `${ label } cannot point outside the project: ${ filePath }` );
95
+ }
96
+
97
+ return normalized.split( path.sep ).join( '/' );
98
+ }
99
+
100
+ function resolveRestSchemaPackageConfig() {
101
+ const outputDir = normalizeProjectPath(
102
+ REST_SCHEMA_PACKAGE.outputDir,
103
+ 'REST_SCHEMA_PACKAGE.outputDir'
104
+ );
105
+ const manifestFile = normalizeProjectPath(
106
+ REST_SCHEMA_PACKAGE.manifestFile ?? path.join( outputDir, 'manifest.json' ),
107
+ 'REST_SCHEMA_PACKAGE.manifestFile'
108
+ );
109
+
110
+ if ( outputDir === '.' ) {
111
+ throw new Error(
112
+ 'REST_SCHEMA_PACKAGE.outputDir must not be the project root; use a dedicated subdirectory such as "inc/rest-schemas".'
113
+ );
114
+ }
115
+
116
+ if ( manifestFile === outputDir || ! manifestFile.startsWith( `${ outputDir }/` ) ) {
117
+ throw new Error(
118
+ `REST_SCHEMA_PACKAGE.manifestFile must stay inside REST_SCHEMA_PACKAGE.outputDir (${ outputDir }): ${ manifestFile }`
119
+ );
120
+ }
121
+
122
+ return {
123
+ manifestFile,
124
+ outputDir,
125
+ };
126
+ }
127
+
128
+ function assertSafeSchemaBaseName( baseName: string, owner: string ) {
129
+ if (
130
+ ! /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test( baseName ) ||
131
+ baseName === '.' ||
132
+ baseName === '..'
133
+ ) {
134
+ throw new Error(
135
+ `${ owner }: REST schema contract key must be a file basename containing only letters, numbers, dots, underscores, or hyphens: ${ baseName }`
136
+ );
137
+ }
138
+ }
139
+
140
+ function getPackagePath( outputDir: string, owner: string, ...segments: string[] ) {
141
+ const packagePath = normalizeProjectPath(
142
+ path.join( outputDir, ...segments ),
143
+ 'REST schema package path'
144
+ );
145
+
146
+ if ( ! packagePath.startsWith( `${ outputDir }/` ) ) {
147
+ throw new Error(
148
+ `${ owner }: REST schema package path must stay inside ${ outputDir }: ${ packagePath }`
149
+ );
150
+ }
151
+
152
+ return packagePath;
153
+ }
154
+
39
155
  function isRestEnabledBlock(
40
156
  block: WorkspaceBlockConfig
41
157
  ): block is WorkspaceBlockConfig & {
@@ -98,6 +214,307 @@ function isWorkspacePostMetaContract(
98
214
  );
99
215
  }
100
216
 
217
+ function getBlockRestSchemaEntries(
218
+ block: WorkspaceBlockConfig & {
219
+ restManifest: NonNullable< WorkspaceBlockConfig[ 'restManifest' ] >;
220
+ },
221
+ outputDir: string
222
+ ): RuntimeRestSchemaPackageEntry[] {
223
+ return Object.keys( block.restManifest.contracts ).map( ( baseName ) => {
224
+ const owner = `Block ${ block.slug }`;
225
+ assertSafeSchemaBaseName( baseName, owner );
226
+
227
+ return {
228
+ id: `block:${ block.slug }:${ baseName }`,
229
+ kind: 'block',
230
+ owner: block.slug,
231
+ path: getPackagePath(
232
+ outputDir,
233
+ owner,
234
+ 'blocks',
235
+ block.slug,
236
+ `${ baseName }.schema.json`
237
+ ),
238
+ schema: baseName,
239
+ source: normalizeProjectPath(
240
+ path.join(
241
+ 'src',
242
+ 'blocks',
243
+ block.slug,
244
+ 'api-schemas',
245
+ `${ baseName }.schema.json`
246
+ ),
247
+ 'REST schema source path'
248
+ ),
249
+ };
250
+ } );
251
+ }
252
+
253
+ function getRestResourceSchemaEntries(
254
+ resource: WorkspaceRestResourceConfig & {
255
+ restManifest: NonNullable< WorkspaceRestResourceConfig[ 'restManifest' ] >;
256
+ typesFile: string;
257
+ },
258
+ outputDir: string
259
+ ): RuntimeRestSchemaPackageEntry[] {
260
+ return Object.keys( resource.restManifest.contracts ).map( ( baseName ) => {
261
+ const owner = `REST resource ${ resource.slug }`;
262
+ assertSafeSchemaBaseName( baseName, owner );
263
+
264
+ return {
265
+ id: `rest:${ resource.slug }:${ baseName }`,
266
+ kind: 'rest-resource',
267
+ owner: resource.slug,
268
+ path: getPackagePath(
269
+ outputDir,
270
+ owner,
271
+ 'rest',
272
+ resource.slug,
273
+ `${ baseName }.schema.json`
274
+ ),
275
+ schema: baseName,
276
+ source: normalizeProjectPath(
277
+ path.join(
278
+ path.dirname( resource.typesFile ),
279
+ 'api-schemas',
280
+ `${ baseName }.schema.json`
281
+ ),
282
+ 'REST schema source path'
283
+ ),
284
+ };
285
+ } );
286
+ }
287
+
288
+ function buildRuntimeRestSchemaPackageManifest(
289
+ restBlocks: Array<
290
+ WorkspaceBlockConfig & {
291
+ restManifest: NonNullable< WorkspaceBlockConfig[ 'restManifest' ] >;
292
+ }
293
+ >,
294
+ restResources: Array<
295
+ WorkspaceRestResourceConfig & {
296
+ restManifest: NonNullable< WorkspaceRestResourceConfig[ 'restManifest' ] >;
297
+ typesFile: string;
298
+ }
299
+ >
300
+ ): RuntimeRestSchemaPackageManifest {
301
+ const { outputDir } = resolveRestSchemaPackageConfig();
302
+ const schemas = [
303
+ ...restBlocks.flatMap( ( block ) =>
304
+ getBlockRestSchemaEntries( block, outputDir )
305
+ ),
306
+ ...restResources.flatMap( ( resource ) =>
307
+ getRestResourceSchemaEntries( resource, outputDir )
308
+ ),
309
+ ].sort(
310
+ ( left, right ) =>
311
+ left.path.localeCompare( right.path ) || left.id.localeCompare( right.id )
312
+ );
313
+ const seenPaths = new Set< string >();
314
+ const seenIds = new Set< string >();
315
+
316
+ for ( const schema of schemas ) {
317
+ if ( seenIds.has( schema.id ) ) {
318
+ throw new Error( `Duplicate runtime REST schema package id: ${ schema.id }` );
319
+ }
320
+
321
+ if ( seenPaths.has( schema.path ) ) {
322
+ throw new Error(
323
+ `Duplicate runtime REST schema package path: ${ schema.path }`
324
+ );
325
+ }
326
+
327
+ seenIds.add( schema.id );
328
+ seenPaths.add( schema.path );
329
+ }
330
+
331
+ return {
332
+ generatedBy: 'wp-typia sync-rest --package',
333
+ outputDir,
334
+ schemaVersion: 1,
335
+ schemas,
336
+ };
337
+ }
338
+
339
+ async function readUtf8IfExists( filePath: string ) {
340
+ try {
341
+ return await fsp.readFile( filePath, 'utf8' );
342
+ } catch ( error ) {
343
+ if ( getNodeErrorCode( error ) === 'ENOENT' ) {
344
+ return undefined;
345
+ }
346
+
347
+ throw error;
348
+ }
349
+ }
350
+
351
+ async function writeUtf8IfChanged( filePath: string, content: string ) {
352
+ const existing = await readUtf8IfExists( filePath );
353
+ if ( existing === content ) {
354
+ return;
355
+ }
356
+
357
+ await fsp.mkdir( path.dirname( filePath ), { recursive: true } );
358
+ await fsp.writeFile( filePath, content, 'utf8' );
359
+ }
360
+
361
+ async function unlinkIfExists( filePath: string ) {
362
+ try {
363
+ await fsp.unlink( filePath );
364
+ } catch ( error ) {
365
+ if ( getNodeErrorCode( error ) === 'ENOENT' ) {
366
+ return;
367
+ }
368
+
369
+ throw error;
370
+ }
371
+ }
372
+
373
+ async function collectPackagedSchemaFiles( outputDir: string ) {
374
+ const schemaFiles: string[] = [];
375
+
376
+ async function walk( relativeDir: string ) {
377
+ const currentDir =
378
+ relativeDir.length > 0 ? path.join( outputDir, relativeDir ) : outputDir;
379
+ let directoryEntries: import( 'node:fs' ).Dirent[];
380
+
381
+ try {
382
+ directoryEntries = await fsp.readdir( currentDir, {
383
+ withFileTypes: true,
384
+ } );
385
+ } catch ( error ) {
386
+ if ( getNodeErrorCode( error ) === 'ENOENT' ) {
387
+ return;
388
+ }
389
+
390
+ throw error;
391
+ }
392
+
393
+ for ( const directoryEntry of directoryEntries ) {
394
+ const relativePath = path.join( relativeDir, directoryEntry.name );
395
+
396
+ if ( directoryEntry.isDirectory() ) {
397
+ await walk( relativePath );
398
+ continue;
399
+ }
400
+
401
+ if (
402
+ directoryEntry.isFile() &&
403
+ directoryEntry.name.endsWith( '.schema.json' )
404
+ ) {
405
+ schemaFiles.push(
406
+ normalizeProjectPath(
407
+ path.join( outputDir, relativePath ),
408
+ 'REST schema package file'
409
+ )
410
+ );
411
+ }
412
+ }
413
+ }
414
+
415
+ await walk( '' );
416
+ return schemaFiles.sort();
417
+ }
418
+
419
+ async function syncRuntimeRestSchemaPackage(
420
+ restBlocks: Array<
421
+ WorkspaceBlockConfig & {
422
+ restManifest: NonNullable< WorkspaceBlockConfig[ 'restManifest' ] >;
423
+ }
424
+ >,
425
+ restResources: Array<
426
+ WorkspaceRestResourceConfig & {
427
+ restManifest: NonNullable< WorkspaceRestResourceConfig[ 'restManifest' ] >;
428
+ typesFile: string;
429
+ }
430
+ >,
431
+ options: { check: boolean }
432
+ ) {
433
+ const { manifestFile, outputDir } = resolveRestSchemaPackageConfig();
434
+ const manifest = buildRuntimeRestSchemaPackageManifest(
435
+ restBlocks,
436
+ restResources
437
+ );
438
+ const manifestContent = `${ JSON.stringify( manifest, null, 2 ) }\n`;
439
+ const expectedPackagePaths = new Set(
440
+ manifest.schemas.map( ( schema ) => schema.path )
441
+ );
442
+ const packageFailures: string[] = [];
443
+
444
+ for ( const schema of manifest.schemas ) {
445
+ const sourceContent = await readUtf8IfExists( schema.source );
446
+
447
+ if ( sourceContent === undefined ) {
448
+ if ( ! options.check ) {
449
+ throw new Error(
450
+ `Runtime REST schema source is missing: ${ schema.source }\nRun \`sync-rest\` before \`sync-rest --package\` to generate source schemas.`
451
+ );
452
+ }
453
+
454
+ packageFailures.push( `${ schema.source } (source missing)` );
455
+ continue;
456
+ }
457
+
458
+ if ( options.check ) {
459
+ const packagedContent = await readUtf8IfExists( schema.path );
460
+
461
+ if ( packagedContent === undefined ) {
462
+ packageFailures.push( `${ schema.path } (missing)` );
463
+ continue;
464
+ }
465
+
466
+ if ( packagedContent !== sourceContent ) {
467
+ packageFailures.push( `${ schema.path } (stale)` );
468
+ }
469
+
470
+ continue;
471
+ }
472
+
473
+ await writeUtf8IfChanged( schema.path, sourceContent );
474
+ }
475
+
476
+ const existingPackagedSchemas = await collectPackagedSchemaFiles( outputDir );
477
+ for ( const schemaPath of existingPackagedSchemas ) {
478
+ if ( expectedPackagePaths.has( schemaPath ) ) {
479
+ continue;
480
+ }
481
+
482
+ if ( options.check ) {
483
+ packageFailures.push( `${ schemaPath } (unexpected)` );
484
+ continue;
485
+ }
486
+
487
+ await unlinkIfExists( schemaPath );
488
+ }
489
+
490
+ if ( options.check ) {
491
+ const existingManifestContent = await readUtf8IfExists( manifestFile );
492
+
493
+ if ( existingManifestContent === undefined ) {
494
+ if ( manifest.schemas.length > 0 ) {
495
+ packageFailures.push( `${ manifestFile } (missing)` );
496
+ }
497
+ } else if ( existingManifestContent !== manifestContent ) {
498
+ packageFailures.push( `${ manifestFile } (stale)` );
499
+ }
500
+
501
+ if ( packageFailures.length > 0 ) {
502
+ throw new Error(
503
+ `Runtime REST schema package is missing or stale:\n${ packageFailures
504
+ .map( ( failure ) => `- ${ failure }` )
505
+ .join(
506
+ '\n'
507
+ ) }\nRun \`sync-rest --package\` to refresh packaged REST schemas before building a release zip.`
508
+ );
509
+ }
510
+
511
+ return manifest.schemas.length;
512
+ }
513
+
514
+ await writeUtf8IfChanged( manifestFile, manifestContent );
515
+ return manifest.schemas.length;
516
+ }
517
+
101
518
  async function assertTypeArtifactsCurrent( block: WorkspaceBlockConfig ) {
102
519
  const report = await runSyncBlockMetadata(
103
520
  {
@@ -139,6 +556,22 @@ async function main() {
139
556
  postMetaContracts.length === 0 &&
140
557
  restResources.length === 0
141
558
  ) {
559
+ if ( options.packageSchemas ) {
560
+ const packagedSchemaCount = await syncRuntimeRestSchemaPackage(
561
+ restBlocks,
562
+ restResources,
563
+ {
564
+ check: options.check,
565
+ }
566
+ );
567
+
568
+ console.log(
569
+ options.check
570
+ ? `✅ Runtime REST schema package is already up to date (${ packagedSchemaCount } schemas).`
571
+ : `✅ Runtime REST schema package generated (${ packagedSchemaCount } schemas).`
572
+ );
573
+ }
574
+
142
575
  console.log(
143
576
  options.check
144
577
  ? 'ℹ️ No REST-enabled workspace blocks, standalone contracts, post meta contracts, or plugin-level REST resources are registered yet. `sync-rest --check` is already clean.'
@@ -281,6 +714,22 @@ async function main() {
281
714
  );
282
715
  }
283
716
 
717
+ if ( options.packageSchemas ) {
718
+ const packagedSchemaCount = await syncRuntimeRestSchemaPackage(
719
+ restBlocks,
720
+ restResources,
721
+ {
722
+ check: options.check,
723
+ }
724
+ );
725
+
726
+ console.log(
727
+ options.check
728
+ ? `✅ Runtime REST schema package is already up to date (${ packagedSchemaCount } schemas).`
729
+ : `✅ Runtime REST schema package generated (${ packagedSchemaCount } schemas).`
730
+ );
731
+ }
732
+
284
733
  console.log(
285
734
  options.check
286
735
  ? '✅ REST contract schemas, standalone schemas, post meta schemas, portable API clients, and endpoint-aware OpenAPI documents are already up to date for workspace blocks, standalone contracts, post meta contracts, and plugin-level resources!'
@@ -17,6 +17,15 @@ if ( ! defined( 'ABSPATH' ) ) {
17
17
  exit;
18
18
  }
19
19
 
20
+ function {{phpPrefix}}_load_rest_schema_helpers() {
21
+ $helper_path = __DIR__ . '/inc/rest-schema.php';
22
+ if ( is_readable( $helper_path ) ) {
23
+ require_once $helper_path;
24
+ }
25
+ }
26
+
27
+ {{phpPrefix}}_load_rest_schema_helpers();
28
+
20
29
  foreach ( glob( __DIR__ . '/src/blocks/*/server.php' ) ?: array() as $server_module ) {
21
30
  require_once $server_module;
22
31
  }