@wp-typia/create-workspace-template 0.17.0 → 0.18.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.
package/README.md CHANGED
@@ -6,20 +6,119 @@ 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 -- pattern hero-layout
20
+ npm run wp-typia:add -- pattern hero-photo-section --scope section --section-role hero --tags hero,image
21
+ npm run wp-typia:add -- binding-source hero-data
22
+ npm run wp-typia:add -- binding-source hero-data --block my-block --attribute headline
23
+ npm run wp-typia:add -- contract external-retrieve-response --type ExternalRetrieveResponse
24
+ 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
25
+ npm run wp-typia:add -- post-meta integration-state --post-type post --type IntegrationStateMeta
26
+ npm run wp-typia:add -- binding-source integration-state-source --from-post-meta integration-state --meta-path status --block my-block --attribute headline
27
+ npm run wp-typia:add -- editor-plugin review-workflow --slot sidebar
28
+ npm run wp-typia:add -- editor-plugin seo-notes --slot document-setting-panel
29
+ npm run wp-typia:add -- hooked-block my-block --anchor core/post-content --position after
25
30
  ```
31
+
32
+ Typed block nesting rules live in `BLOCK_NESTING` inside
33
+ `scripts/block-config.ts`. Declare `parent`, `ancestor`, or `allowedBlocks`
34
+ relationships there and `wp-typia sync --check` will validate referenced block
35
+ names in the workspace namespace while allowing external targets like
36
+ `core/group`, then keep the matching `block.json` metadata current.
37
+ Declare default editor `InnerBlocks` templates in `BLOCK_TEMPLATES` and
38
+ `wp-typia sync` will generate `src/inner-blocks-templates.ts` with constants you
39
+ can import from edit components.
40
+ Pattern files listed in `PATTERNS` are parsed during the same sync flow so
41
+ `wp-typia sync --check` can first validate typed catalog metadata such as
42
+ duplicate slugs, `scope`, `sectionRole`, `tags`, `thumbnailUrl`, and
43
+ `contentFile`, then catch serialized block content that violates the declared
44
+ `allowedBlocks`, `parent`, or `ancestor` rules. Unknown or unparseable pattern
45
+ blocks are reported as warnings so the first implementation stays non-mutating.
46
+ The validator reads serialized `<!-- wp:* -->` block comment boundaries
47
+ conservatively and does not execute dynamic PHP content.
48
+ For a complete generic family with a container, section, title, body, and media
49
+ block, see the Nesting Contracts Guide in the hosted docs and the checked
50
+ fixture at `tests/fixtures/nested-block-family.ts`.
51
+
52
+ ## CLI binary policy
53
+
54
+ Official workspace projects install `wp-typia` as a local devDependency and
55
+ expose package scripts for the common CLI entrypoints:
56
+
57
+ ```json
58
+ {
59
+ "scripts": {
60
+ "sync-rest:package": "tsx scripts/sync-rest-contracts.ts --package",
61
+ "sync-rest:package:check": "tsx scripts/sync-rest-contracts.ts --package --check",
62
+ "wp-typia:sync": "wp-typia sync",
63
+ "wp-typia:doctor": "wp-typia doctor",
64
+ "wp-typia:doctor:workspace": "wp-typia doctor --workspace-only",
65
+ "wp-typia:add": "wp-typia add"
66
+ },
67
+ "devDependencies": {
68
+ "wp-typia": "<version>"
69
+ }
70
+ }
71
+ ```
72
+
73
+ ## Minimal Workspace vs Plugin QA Profile
74
+
75
+ Use the default workspace template when you want the smallest editable shell and
76
+ plan to add QA infrastructure later. Use `--profile plugin-qa` when the plugin
77
+ should start with local WordPress smoke checks, `.wp-env.json`, `.env.example`,
78
+ `scripts/integration-smoke/local-smoke.mjs`, and release zip scripts.
79
+
80
+ Existing minimal workspaces can adopt the same QA surface later:
81
+
82
+ ```bash
83
+ npm run wp-typia:add -- integration-env local-smoke --wp-env --release-zip
84
+ ```
85
+
86
+ Use the script form from generated workspaces so the executable CLI package is
87
+ clearly separated from support packages such as `@wp-typia/project-tools`.
88
+
89
+ Workspaces that ship REST resources can run `sync-rest:package` before release
90
+ builds to copy generated runtime schemas into `inc/rest-schemas`. Add
91
+ `sync-rest:package:check` to release CI so stale or missing packaged schemas are
92
+ caught before a zip is built without TypeScript source files.
93
+
94
+ The generated `inc/rest-schema.php` helper centralizes schema loading and
95
+ WordPress REST sanitization. Generated REST resources use it automatically, and
96
+ custom PHP resources can call
97
+ `<phpPrefix>_get_wordpress_rest_schema( 'settings-response', array( 'resource' => 'settings' ) )`
98
+ or `<phpPrefix>_validate_and_sanitize_rest_payload(...)` to reuse the same
99
+ packaged/source lookup and `WP_Error` handling.
100
+
101
+ Use generated REST resources when the workspace should own the PHP route glue.
102
+ Use `wp-typia add rest-resource <name> --manual` for provider routes owned by
103
+ another plugin, editor contract, or legacy controller. Manual routes can declare
104
+ custom namespaces, `--path` or `--route-pattern` patterns with named captures,
105
+ and route-owner metadata such as `--permission-callback`,
106
+ `--controller-class`, and `--controller-extends` while still generating typed
107
+ schemas, OpenAPI, clients, and drift checks.
108
+
109
+ Manual settings contracts can model write-only credentials with
110
+ `--secret-field <field>`. The generated request type marks the field as
111
+ `tags.Secret<"has<Field>">` plus `tags.PreserveOnEmpty<true>`, response types
112
+ expose only the safe has-value field, and generated admin settings screens omit
113
+ blank secrets so existing stored values are preserved by default.
114
+
115
+ | Package manager | Doctor | Sync check | Add a block |
116
+ | --------------- | -------------------------- | ---------------------------------- | --------------------------------------------------------- |
117
+ | npm | `npm run wp-typia:doctor` | `npm run wp-typia:sync -- --check` | `npm run wp-typia:add -- block my-block --template basic` |
118
+ | pnpm | `pnpm run wp-typia:doctor` | `pnpm run wp-typia:sync --check` | `pnpm run wp-typia:add block my-block --template basic` |
119
+ | bun | `bun run wp-typia:doctor` | `bun run wp-typia:sync --check` | `bun run wp-typia:add block my-block --template basic` |
120
+ | yarn | `yarn run wp-typia:doctor` | `yarn run wp-typia:sync --check` | `yarn run wp-typia:add block my-block --template basic` |
121
+
122
+ Existing workspaces can adopt the same scripts without regenerating. Pin
123
+ `wp-typia` to the project version you want CI to run, then use
124
+ `wp-typia:doctor:workspace` when optional local runtimes should be advisory.
@@ -7,31 +7,106 @@
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 pattern hero-photo-section --scope section --section-role hero --tags hero,image
42
+ bun run wp-typia:add rest-resource snapshots --namespace {{namespace}}/v1 --methods list,read,create
43
+ 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
44
+ bun run wp-typia:add post-meta integration-state --post-type post
45
+ bun run wp-typia:add binding-source integration-state-source --from-post-meta integration-state --meta-path status --block counter-card --attribute headline
33
46
  ```
34
47
 
48
+ Typed block nesting rules live in `BLOCK_NESTING` inside
49
+ `scripts/block-config.ts`. Declare `parent`, `ancestor`, or `allowedBlocks`
50
+ relationships there and `wp-typia sync --check` will validate referenced block
51
+ names in the workspace namespace while allowing external targets like
52
+ `core/group`, then keep the matching `block.json` metadata current.
53
+ Declare default editor `InnerBlocks` templates in `BLOCK_TEMPLATES` and
54
+ `wp-typia sync` will generate `src/inner-blocks-templates.ts` with constants you
55
+ can import from edit components.
56
+ Pattern files listed in `PATTERNS` are parsed during the same sync flow so
57
+ `wp-typia sync --check` can first validate typed catalog metadata such as
58
+ duplicate slugs, `scope`, `sectionRole`, `tags`, `thumbnailUrl`, and
59
+ `contentFile`, then catch serialized block content that violates the declared
60
+ `allowedBlocks`, `parent`, or `ancestor` rules. Unknown or unparseable pattern
61
+ blocks are reported as warnings so the first implementation stays non-mutating.
62
+ The validator reads serialized `<!-- wp:* -->` block comment boundaries
63
+ conservatively and does not execute dynamic PHP content.
64
+ For a complete generic family with a container, section, title, body, and media
65
+ block, see the Nesting Contracts Guide in the hosted docs and the checked
66
+ fixture at `tests/fixtures/nested-block-family.ts`.
67
+
68
+ ## CLI Commands
69
+
70
+ This workspace installs the executable `wp-typia` CLI as a local devDependency
71
+ and exposes stable package scripts so support packages are not confused with the
72
+ binary package:
73
+
74
+ ```bash
75
+ bun run wp-typia:doctor
76
+ bun run wp-typia:doctor:workspace
77
+ bun run wp-typia:sync --check
78
+ bun run sync-rest:package:check
79
+ bun run wp-typia:add block counter-card --template basic
80
+ ```
81
+
82
+ Use `wp-typia:doctor:workspace` in CI when optional local runtimes are advisory
83
+ but workspace checks must still fail the job.
84
+
85
+ Run `sync-rest:package` before building release zips when REST resources are
86
+ registered. It copies generated runtime schemas into `inc/rest-schemas` and
87
+ `sync-rest:package:check` fails CI if packaged schemas are missing or stale.
88
+
89
+ The generated `inc/rest-schema.php` helper centralizes schema loading and
90
+ WordPress REST sanitization. Generated REST resources use it automatically, and
91
+ custom PHP resources can call
92
+ `{{phpPrefix}}_get_wordpress_rest_schema( 'settings-response', array( 'resource' => 'settings' ) )`
93
+ or `{{phpPrefix}}_validate_and_sanitize_rest_payload(...)` to reuse the same
94
+ packaged/source lookup and `WP_Error` handling.
95
+
96
+ Use generated REST resources when the workspace should own the PHP route glue.
97
+ Use `wp-typia add rest-resource <name> --manual` for provider routes owned by
98
+ another plugin, editor contract, or legacy controller. Manual routes can declare
99
+ custom namespaces, `--path` or `--route-pattern` patterns with named captures,
100
+ and route-owner metadata such as `--permission-callback`,
101
+ `--controller-class`, and `--controller-extends` while still generating typed
102
+ schemas, OpenAPI, clients, and drift checks.
103
+
104
+ Manual settings contracts can model write-only credentials with
105
+ `--secret-field <field>`. The generated request type marks the field as
106
+ `tags.Secret<"has<Field>">` plus `tags.PreserveOnEmpty<true>`, response types
107
+ expose only the safe has-value field, and generated admin settings screens omit
108
+ blank secrets so existing stored values are preserved by default.
109
+
35
110
  ## Development
36
111
 
37
112
  ```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.18.0",
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",
@@ -1,3 +1,8 @@
1
+ import {
2
+ defineBlockNesting,
3
+ defineInnerBlocksTemplates,
4
+ } from '@wp-typia/block-runtime/metadata-core';
5
+
1
6
  export interface WorkspaceBlockConfig {
2
7
  slug: string;
3
8
  attributeTypeName: string;
@@ -9,6 +14,14 @@ export interface WorkspaceBlockConfig {
9
14
  >;
10
15
  }
11
16
 
17
+ export const BLOCK_NESTING = defineBlockNesting( {
18
+ // Add parent, ancestor, and allowedBlocks relationships here.
19
+ } );
20
+
21
+ export const BLOCK_TEMPLATES = defineInnerBlocksTemplates( {
22
+ // Add default InnerBlocks templates here.
23
+ } );
24
+
12
25
  export interface WorkspaceVariationConfig {
13
26
  block: string;
14
27
  file: string;
@@ -30,14 +43,22 @@ export interface WorkspaceBlockTransformConfig {
30
43
  }
31
44
 
32
45
  export interface WorkspacePatternConfig {
33
- file: string;
46
+ contentFile?: string;
47
+ file?: string;
48
+ scope?: 'full' | 'section';
49
+ sectionRole?: string;
34
50
  slug: string;
51
+ tags?: string[];
52
+ thumbnailUrl?: string;
53
+ title?: string;
35
54
  }
36
55
 
37
56
  export interface WorkspaceBindingSourceConfig {
38
57
  attribute?: string;
39
58
  block?: string;
40
59
  editorFile: string;
60
+ metaPath?: string;
61
+ postMeta?: string;
41
62
  serverFile: string;
42
63
  slug: string;
43
64
  }
@@ -63,6 +84,7 @@ export interface WorkspaceRestResourceBaseConfig {
63
84
  typeof import( '@wp-typia/block-runtime/metadata-core' ).defineEndpointManifest
64
85
  >;
65
86
  secretFieldName?: string;
87
+ secretPreserveOnEmpty?: boolean;
66
88
  secretStateFieldName?: string;
67
89
  slug: string;
68
90
  typesFile: string;
@@ -114,6 +136,11 @@ export interface WorkspaceAdminViewConfig {
114
136
  source?: string;
115
137
  }
116
138
 
139
+ export interface WorkspaceRestSchemaPackageConfig {
140
+ manifestFile?: string;
141
+ outputDir: string;
142
+ }
143
+
117
144
  export const BLOCKS: WorkspaceBlockConfig[] = [
118
145
  // wp-typia add block entries
119
146
  ];
@@ -157,3 +184,8 @@ export const EDITOR_PLUGINS: WorkspaceEditorPluginConfig[] = [
157
184
  export const ADMIN_VIEWS: WorkspaceAdminViewConfig[] = [
158
185
  // wp-typia add admin-view entries
159
186
  ];
187
+
188
+ export const REST_SCHEMA_PACKAGE: WorkspaceRestSchemaPackageConfig = {
189
+ manifestFile: 'inc/rest-schemas/manifest.json',
190
+ outputDir: 'inc/rest-schemas',
191
+ };
@@ -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!'
@@ -1,9 +1,22 @@
1
1
  /* eslint-disable no-console */
2
+ import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
 
4
- import { syncBlockMetadata } from '@wp-typia/block-runtime/metadata-core';
5
+ import {
6
+ formatBlockPatternContentNestingDiagnostics,
7
+ validateBlockPatternContentNesting,
8
+ syncInnerBlocksTemplateModule,
9
+ syncBlockMetadata,
10
+ validateBlockNestingContract,
11
+ validateInnerBlocksTemplates,
12
+ } from '@wp-typia/block-runtime/metadata-core';
13
+ import {
14
+ formatPatternCatalogDiagnostics,
15
+ resolvePatternCatalogContentFile,
16
+ validatePatternCatalog,
17
+ } from '@wp-typia/project-tools/pattern-catalog';
5
18
 
6
- import { BLOCKS } from './block-config';
19
+ import { BLOCK_NESTING, BLOCK_TEMPLATES, BLOCKS, PATTERNS } from './block-config';
7
20
 
8
21
  function parseCliOptions( argv: string[] ) {
9
22
  const options = {
@@ -22,9 +35,129 @@ function parseCliOptions( argv: string[] ) {
22
35
  return options;
23
36
  }
24
37
 
38
+ function readWorkspaceBlockName( block: ( typeof BLOCKS )[ number ] ): string {
39
+ const blockJsonFile = path.join( 'src', 'blocks', block.slug, 'block.json' );
40
+ let blockJson: { name?: unknown };
41
+ try {
42
+ blockJson = JSON.parse( fs.readFileSync( blockJsonFile, 'utf8' ) ) as {
43
+ name?: unknown;
44
+ };
45
+ } catch ( error ) {
46
+ throw new Error(
47
+ `Failed to read block.json for block "${ block.slug }" at ${ blockJsonFile }: ${
48
+ error instanceof Error ? error.message : String( error )
49
+ }`
50
+ );
51
+ }
52
+ if ( typeof blockJson.name !== 'string' || blockJson.name.trim() === '' ) {
53
+ throw new Error( `${ blockJsonFile } must define a string "name".` );
54
+ }
55
+
56
+ return blockJson.name;
57
+ }
58
+
59
+ function readPatternFileContent( pattern: ( typeof PATTERNS )[ number ] ): string {
60
+ const patternFile = resolvePatternCatalogContentFile( pattern );
61
+ if ( ! patternFile ) {
62
+ throw new Error(
63
+ `Pattern "${ pattern.slug }" must declare contentFile or file in scripts/block-config.ts.`
64
+ );
65
+ }
66
+ try {
67
+ return fs.readFileSync( patternFile, 'utf8' );
68
+ } catch ( error ) {
69
+ throw new Error(
70
+ `Failed to read pattern "${ pattern.slug }" at ${ patternFile }: ${
71
+ error instanceof Error ? error.message : String( error )
72
+ }`
73
+ );
74
+ }
75
+ }
76
+
77
+ function validatePatternCatalogMetadata() {
78
+ const result = validatePatternCatalog( PATTERNS, {
79
+ projectDir: process.cwd(),
80
+ } );
81
+
82
+ if ( result.warnings.length > 0 ) {
83
+ console.warn(
84
+ `⚠️ Pattern catalog validation warnings:\n${ formatPatternCatalogDiagnostics(
85
+ result.warnings
86
+ ) }`
87
+ );
88
+ }
89
+
90
+ if ( result.errors.length > 0 ) {
91
+ throw new Error(
92
+ `Pattern catalog metadata is invalid:\n${ formatPatternCatalogDiagnostics(
93
+ result.errors
94
+ ) }`
95
+ );
96
+ }
97
+ }
98
+
99
+ function validatePatternContentNesting( knownBlockNames: readonly string[] ) {
100
+ const diagnostics = PATTERNS.flatMap( ( pattern ) => {
101
+ const content = readPatternFileContent( pattern );
102
+ return validateBlockPatternContentNesting( content, {
103
+ allowExternalBlockNames: true,
104
+ knownBlockNames,
105
+ nesting: BLOCK_NESTING,
106
+ patternFile: resolvePatternCatalogContentFile( pattern ),
107
+ } ).diagnostics;
108
+ } );
109
+ const warnings = diagnostics.filter(
110
+ ( diagnostic ) => diagnostic.severity === 'warning'
111
+ );
112
+ const errors = diagnostics.filter(
113
+ ( diagnostic ) => diagnostic.severity === 'error'
114
+ );
115
+
116
+ if ( warnings.length > 0 ) {
117
+ console.warn(
118
+ `⚠️ Pattern nesting validation warnings:\n${ formatBlockPatternContentNestingDiagnostics(
119
+ warnings
120
+ ) }`
121
+ );
122
+ }
123
+
124
+ if ( errors.length > 0 ) {
125
+ throw new Error(
126
+ `Pattern content violates block nesting contract:\n${ formatBlockPatternContentNestingDiagnostics(
127
+ errors
128
+ ) }`
129
+ );
130
+ }
131
+ }
132
+
25
133
  async function main() {
26
134
  const options = parseCliOptions( process.argv.slice( 2 ) );
27
135
 
136
+ const knownBlockNames = BLOCKS.map( readWorkspaceBlockName );
137
+ validateBlockNestingContract( BLOCK_NESTING, {
138
+ allowExternalBlockNames: true,
139
+ knownBlockNames,
140
+ } );
141
+ validateInnerBlocksTemplates( BLOCK_TEMPLATES, {
142
+ allowExternalBlockNames: true,
143
+ knownBlockNames,
144
+ nesting: BLOCK_NESTING,
145
+ } );
146
+ validatePatternCatalogMetadata();
147
+ validatePatternContentNesting( knownBlockNames );
148
+ await syncInnerBlocksTemplateModule(
149
+ {
150
+ allowExternalBlockNames: true,
151
+ knownBlockNames,
152
+ nesting: BLOCK_NESTING,
153
+ outputFile: path.join( 'src', 'inner-blocks-templates.ts' ),
154
+ templates: BLOCK_TEMPLATES,
155
+ },
156
+ {
157
+ check: options.check,
158
+ }
159
+ );
160
+
28
161
  if ( BLOCKS.length === 0 ) {
29
162
  console.log(
30
163
  options.check
@@ -38,9 +171,12 @@ async function main() {
38
171
  const baseDir = path.join( 'src', 'blocks', block.slug );
39
172
  const result = await syncBlockMetadata(
40
173
  {
174
+ allowExternalBlockNames: true,
41
175
  blockJsonFile: path.join( baseDir, 'block.json' ),
42
176
  jsonSchemaFile: path.join( baseDir, 'typia.schema.json' ),
177
+ knownBlockNames,
43
178
  manifestFile: path.join( baseDir, 'typia.manifest.json' ),
179
+ nesting: BLOCK_NESTING,
44
180
  openApiFile: path.join( baseDir, 'typia.openapi.json' ),
45
181
  sourceTypeName: block.attributeTypeName,
46
182
  typesFile: block.typesFile,
@@ -0,0 +1,13 @@
1
+ /* This file is generated by wp-typia. Do not edit manually. */
2
+
3
+ export type WpTypiaInnerBlocksTemplateAttributes = Record<string, unknown>;
4
+ export type WpTypiaInnerBlocksTemplateItem = [
5
+ blockName: string,
6
+ attributes?: WpTypiaInnerBlocksTemplateAttributes,
7
+ innerBlocks?: WpTypiaInnerBlocksTemplate,
8
+ ];
9
+ export type WpTypiaInnerBlocksTemplate = WpTypiaInnerBlocksTemplateItem[];
10
+
11
+ export const INNER_BLOCKS_TEMPLATES = {} satisfies Record<string, WpTypiaInnerBlocksTemplate>;
12
+
13
+ export type WpTypiaInnerBlocksTemplateName = keyof typeof INNER_BLOCKS_TEMPLATES;
@@ -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
  }
@@ -168,7 +177,11 @@ function {{phpPrefix}}_register_pattern_category() {
168
177
  }
169
178
 
170
179
  function {{phpPrefix}}_register_patterns() {
171
- foreach ( glob( __DIR__ . '/src/patterns/*.php' ) ?: array() as $pattern_module ) {
180
+ $pattern_modules = array_merge(
181
+ glob( __DIR__ . '/src/patterns/*.php' ) ?: array(),
182
+ glob( __DIR__ . '/src/patterns/*/*.php' ) ?: array()
183
+ );
184
+ foreach ( $pattern_modules as $pattern_module ) {
172
185
  require $pattern_module;
173
186
  }
174
187
  }