@wp-typia/project-tools 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/runtime/alternate-render-targets.d.ts +5 -0
  2. package/dist/runtime/alternate-render-targets.js +29 -0
  3. package/dist/runtime/block-generator-service-core.d.ts +2 -2
  4. package/dist/runtime/block-generator-service-core.js +13 -8
  5. package/dist/runtime/block-generator-service-spec.d.ts +10 -2
  6. package/dist/runtime/block-generator-service-spec.js +43 -1
  7. package/dist/runtime/built-in-block-artifacts.js +1 -0
  8. package/dist/runtime/built-in-block-code-templates/compound-child.d.ts +2 -2
  9. package/dist/runtime/built-in-block-code-templates/compound-child.js +35 -2
  10. package/dist/runtime/built-in-block-code-templates/compound-parent.d.ts +2 -2
  11. package/dist/runtime/built-in-block-code-templates/compound-parent.js +204 -27
  12. package/dist/runtime/built-in-block-code-templates/compound-persistence.d.ts +1 -1
  13. package/dist/runtime/built-in-block-code-templates/compound-persistence.js +11 -8
  14. package/dist/runtime/built-in-block-non-ts-artifacts.js +505 -2
  15. package/dist/runtime/cli-add-block.d.ts +6 -2
  16. package/dist/runtime/cli-add-block.js +71 -24
  17. package/dist/runtime/cli-add-shared.d.ts +58 -2
  18. package/dist/runtime/cli-add-shared.js +111 -12
  19. package/dist/runtime/cli-add-workspace-assets.d.ts +21 -1
  20. package/dist/runtime/cli-add-workspace-assets.js +417 -1
  21. package/dist/runtime/cli-add-workspace-rest.d.ts +14 -0
  22. package/dist/runtime/cli-add-workspace-rest.js +1060 -0
  23. package/dist/runtime/cli-add-workspace.d.ts +10 -1
  24. package/dist/runtime/cli-add-workspace.js +10 -1
  25. package/dist/runtime/cli-add.d.ts +3 -3
  26. package/dist/runtime/cli-add.js +2 -2
  27. package/dist/runtime/cli-core.d.ts +5 -1
  28. package/dist/runtime/cli-core.js +3 -1
  29. package/dist/runtime/cli-doctor-workspace.js +135 -1
  30. package/dist/runtime/cli-help.js +12 -7
  31. package/dist/runtime/cli-scaffold.d.ts +12 -2
  32. package/dist/runtime/cli-scaffold.js +222 -46
  33. package/dist/runtime/cli-templates.d.ts +4 -4
  34. package/dist/runtime/cli-templates.js +104 -39
  35. package/dist/runtime/cli-validation.d.ts +66 -0
  36. package/dist/runtime/cli-validation.js +92 -0
  37. package/dist/runtime/compound-inner-blocks.d.ts +78 -0
  38. package/dist/runtime/compound-inner-blocks.js +88 -0
  39. package/dist/runtime/index.d.ts +6 -3
  40. package/dist/runtime/index.js +4 -2
  41. package/dist/runtime/local-dev-presets.js +7 -2
  42. package/dist/runtime/migration-command-surface.js +2 -0
  43. package/dist/runtime/package-versions.d.ts +1 -0
  44. package/dist/runtime/package-versions.js +12 -0
  45. package/dist/runtime/rest-resource-artifacts.d.ts +35 -0
  46. package/dist/runtime/rest-resource-artifacts.js +158 -0
  47. package/dist/runtime/scaffold-answer-resolution.js +78 -8
  48. package/dist/runtime/scaffold-apply-utils.d.ts +4 -3
  49. package/dist/runtime/scaffold-apply-utils.js +34 -17
  50. package/dist/runtime/scaffold-bootstrap.d.ts +15 -0
  51. package/dist/runtime/scaffold-bootstrap.js +29 -7
  52. package/dist/runtime/scaffold-documents.js +24 -3
  53. package/dist/runtime/scaffold-identifiers.d.ts +17 -0
  54. package/dist/runtime/scaffold-identifiers.js +22 -0
  55. package/dist/runtime/scaffold-onboarding.js +25 -13
  56. package/dist/runtime/scaffold-package-manager-files.js +6 -1
  57. package/dist/runtime/scaffold-template-variables.js +22 -0
  58. package/dist/runtime/scaffold.d.ts +22 -1
  59. package/dist/runtime/scaffold.js +56 -11
  60. package/dist/runtime/template-render.d.ts +5 -2
  61. package/dist/runtime/template-render.js +9 -3
  62. package/dist/runtime/template-source-contracts.d.ts +11 -0
  63. package/dist/runtime/template-source-external.d.ts +1 -1
  64. package/dist/runtime/template-source-external.js +45 -13
  65. package/dist/runtime/template-source-normalization.d.ts +1 -1
  66. package/dist/runtime/template-source-normalization.js +5 -1
  67. package/dist/runtime/template-source-remote.d.ts +5 -0
  68. package/dist/runtime/template-source-remote.js +33 -0
  69. package/dist/runtime/template-source.js +35 -4
  70. package/dist/runtime/workspace-inventory.d.ts +43 -1
  71. package/dist/runtime/workspace-inventory.js +132 -1
  72. package/dist/runtime/workspace-project.d.ts +1 -1
  73. package/dist/runtime/workspace-project.js +3 -3
  74. package/package.json +9 -4
  75. package/templates/_shared/compound/core/scripts/add-compound-child.ts.mustache +728 -49
  76. package/templates/query-loop/src/validator-toolkit.ts.mustache +0 -1
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
 
5
5
  const PROJECT_ROOT = process.cwd();
6
6
 
7
- const ALLOWED_CHILD_MARKER = '// add-child: insert new allowed child block names here';
7
+ const CHILD_SPEC_MARKER = '// add-child: insert new child specs here';
8
8
  const BLOCK_CONFIG_MARKER = '// add-child: insert new block config entries here';
9
9
  const CHILD_PLACEHOLDER = 'Add supporting details for this internal item.';
10
10
  const BLOCK_SLUG_PATTERN = /^[a-z][a-z0-9-]*$/;
@@ -16,8 +16,10 @@ type StarterManifestDocument = {
16
16
  };
17
17
 
18
18
  type CompoundParentConfig = {
19
+ blockJsonPath: string;
19
20
  blockName: string;
20
21
  namespace: string;
22
+ blocksRoot: string;
21
23
  slug: string;
22
24
  styleImport: string;
23
25
  textDomain: string;
@@ -25,12 +27,47 @@ type CompoundParentConfig = {
25
27
  typeName: string;
26
28
  };
27
29
 
30
+ type ExistingCompoundChild = {
31
+ ancestorBlockNames: string[];
32
+ blockJsonPath: string;
33
+ blockName: string;
34
+ container: boolean;
35
+ directAllowedBlocks: string[];
36
+ folderSlug: string;
37
+ key: string;
38
+ parentBlockNames: string[];
39
+ placement: 'nested' | 'root';
40
+ supportsInserter: boolean;
41
+ title: string;
42
+ };
43
+
44
+ type ParsedArgs = {
45
+ ancestorValues: string[];
46
+ container: boolean;
47
+ dryRun: boolean;
48
+ inserter?: 'hidden' | 'visible';
49
+ slug?: string;
50
+ title?: string;
51
+ };
52
+
53
+ type CompoundChildGraphNode = {
54
+ blockName: string;
55
+ container: boolean;
56
+ directParentBlockName: string;
57
+ isProspective: boolean;
58
+ key: string;
59
+ placement: 'nested' | 'root';
60
+ supportsInserter: boolean;
61
+ title: string;
62
+ };
63
+
28
64
  function parseArgs() {
29
65
  const args = process.argv.slice( 2 );
30
- const parsed: {
31
- slug?: string;
32
- title?: string;
33
- } = {};
66
+ const parsed: ParsedArgs = {
67
+ ancestorValues: [],
68
+ container: false,
69
+ dryRun: false,
70
+ };
34
71
 
35
72
  for ( let index = 0; index < args.length; index += 1 ) {
36
73
  const arg = args[ index ];
@@ -53,6 +90,40 @@ function parseArgs() {
53
90
  index += 1;
54
91
  continue;
55
92
  }
93
+
94
+ if ( arg === '--ancestor' ) {
95
+ const value = args[ index + 1 ];
96
+ if ( ! value || value.startsWith( '--' ) ) {
97
+ throw new Error( '--ancestor requires a value.' );
98
+ }
99
+ parsed.ancestorValues.push( value );
100
+ index += 1;
101
+ continue;
102
+ }
103
+
104
+ if ( arg === '--container' ) {
105
+ parsed.container = true;
106
+ continue;
107
+ }
108
+
109
+ if ( arg === '--dry-run' ) {
110
+ parsed.dryRun = true;
111
+ continue;
112
+ }
113
+
114
+ if ( arg === '--inserter' ) {
115
+ const value = args[ index + 1 ];
116
+ if ( value !== 'hidden' && value !== 'visible' ) {
117
+ throw new Error( "--inserter must be either 'hidden' or 'visible'." );
118
+ }
119
+ parsed.inserter = value;
120
+ index += 1;
121
+ continue;
122
+ }
123
+
124
+ if ( arg.startsWith( '--' ) ) {
125
+ throw new Error( `Unknown option: ${ arg }.` );
126
+ }
56
127
  }
57
128
 
58
129
  return parsed;
@@ -177,8 +248,10 @@ function resolveCompoundParentConfig(): CompoundParentConfig {
177
248
  : parentSlug;
178
249
 
179
250
  return {
251
+ blockJsonPath,
180
252
  blockName,
181
253
  namespace,
254
+ blocksRoot,
182
255
  slug: parentSlug,
183
256
  styleImport: `../${ parentSlug }/style.scss`,
184
257
  textDomain,
@@ -188,8 +261,10 @@ function resolveCompoundParentConfig(): CompoundParentConfig {
188
261
  }
189
262
 
190
263
  const {
264
+ blockJsonPath: PARENT_BLOCK_JSON_PATH,
191
265
  blockName: PARENT_BLOCK_NAME,
192
266
  namespace: PARENT_BLOCK_NAMESPACE,
267
+ blocksRoot: BLOCKS_ROOT,
193
268
  slug: PARENT_BLOCK_SLUG,
194
269
  styleImport: PARENT_STYLE_IMPORT,
195
270
  textDomain: TEXT_DOMAIN,
@@ -300,50 +375,486 @@ function insertBeforeMarker( filePath: string, marker: string, insertionLines: s
300
375
  );
301
376
  }
302
377
 
378
+ function readBlockJsonDocument(
379
+ filePath: string
380
+ ): Record< string, unknown > {
381
+ return readJsonFile( filePath );
382
+ }
383
+
384
+ function writeBlockJsonDocument(
385
+ filePath: string,
386
+ document: Record< string, unknown >
387
+ ) {
388
+ fs.writeFileSync(
389
+ filePath,
390
+ `${ JSON.stringify( document, null, '\t' ) }\n`,
391
+ 'utf8'
392
+ );
393
+ }
394
+
395
+ function deriveChildKey( folderSlug: string ): string {
396
+ if ( folderSlug.startsWith( `${ PARENT_BLOCK_SLUG }-` ) ) {
397
+ return folderSlug.slice( PARENT_BLOCK_SLUG.length + 1 );
398
+ }
399
+
400
+ return resolveValidatedBlockSlug( folderSlug );
401
+ }
402
+
403
+ function listExistingCompoundChildren(): ExistingCompoundChild[] {
404
+ if ( ! fs.existsSync( BLOCKS_ROOT ) ) {
405
+ return [];
406
+ }
407
+
408
+ return fs
409
+ .readdirSync( BLOCKS_ROOT, { withFileTypes: true } )
410
+ .filter( ( entry ) => entry.isDirectory() )
411
+ .map( ( entry ) => entry.name )
412
+ .filter( ( folderSlug ) => folderSlug !== PARENT_BLOCK_SLUG )
413
+ .filter( ( folderSlug ) => folderSlug.startsWith( `${ PARENT_BLOCK_SLUG }-` ) )
414
+ .map( ( folderSlug ) => {
415
+ const blockJsonPath = path.join( BLOCKS_ROOT, folderSlug, 'block.json' );
416
+ if ( ! fs.existsSync( blockJsonPath ) ) {
417
+ return null;
418
+ }
419
+
420
+ const blockJson = readBlockJsonDocument( blockJsonPath );
421
+ const blockName =
422
+ typeof blockJson.name === 'string' ? blockJson.name.trim() : '';
423
+ if ( blockName.length === 0 ) {
424
+ return null;
425
+ }
426
+
427
+ const ancestorBlockNames = Array.isArray( blockJson.ancestor )
428
+ ? blockJson.ancestor.filter(
429
+ ( value ): value is string => typeof value === 'string'
430
+ )
431
+ : [];
432
+ const parentBlockNames = Array.isArray( blockJson.parent )
433
+ ? blockJson.parent.filter(
434
+ ( value ): value is string => typeof value === 'string'
435
+ )
436
+ : [];
437
+ const directAllowedBlocks = Array.isArray( blockJson.allowedBlocks )
438
+ ? blockJson.allowedBlocks.filter(
439
+ ( value ): value is string => typeof value === 'string'
440
+ )
441
+ : [];
442
+ const supports =
443
+ typeof blockJson.supports === 'object' &&
444
+ blockJson.supports &&
445
+ ! Array.isArray( blockJson.supports )
446
+ ? blockJson.supports
447
+ : null;
448
+ const supportsInserter =
449
+ supports && typeof supports.inserter === 'boolean'
450
+ ? supports.inserter
451
+ : true;
452
+ const title =
453
+ typeof blockJson.title === 'string' &&
454
+ blockJson.title.trim().length > 0
455
+ ? blockJson.title.trim()
456
+ : toTitleCase( deriveChildKey( folderSlug ) );
457
+
458
+ return {
459
+ ancestorBlockNames,
460
+ blockJsonPath,
461
+ blockName,
462
+ container: Object.prototype.hasOwnProperty.call(
463
+ blockJson,
464
+ 'allowedBlocks'
465
+ ),
466
+ directAllowedBlocks,
467
+ folderSlug,
468
+ key: deriveChildKey( folderSlug ),
469
+ parentBlockNames,
470
+ placement: ancestorBlockNames.length > 0 ? 'nested' : 'root',
471
+ supportsInserter,
472
+ title,
473
+ } satisfies ExistingCompoundChild;
474
+ } )
475
+ .filter( ( child ): child is ExistingCompoundChild => child !== null );
476
+ }
477
+
478
+ function buildCompoundChildGraphMap(
479
+ existingChildren: ExistingCompoundChild[]
480
+ ): Map< string, ExistingCompoundChild > {
481
+ return new Map(
482
+ existingChildren.map( ( child ) => [ child.blockName, child ] )
483
+ );
484
+ }
485
+
486
+ function resolveDirectParentBlockName( child: ExistingCompoundChild ): string {
487
+ return (
488
+ child.ancestorBlockNames[ child.ancestorBlockNames.length - 1 ] ??
489
+ child.parentBlockNames[ 0 ] ??
490
+ PARENT_BLOCK_NAME
491
+ );
492
+ }
493
+
494
+ function validateExistingCompoundChildGraph(
495
+ existingChildren: ExistingCompoundChild[]
496
+ ) {
497
+ const seenKeys = new Set< string >();
498
+ const seenBlockNames = new Set< string >();
499
+ const childByBlockName = buildCompoundChildGraphMap( existingChildren );
500
+
501
+ for ( const child of existingChildren ) {
502
+ if ( seenKeys.has( child.key ) ) {
503
+ throw new Error(
504
+ `Existing compound child graph is invalid: child key "${ child.key }" is declared more than once.`
505
+ );
506
+ }
507
+
508
+ if ( seenBlockNames.has( child.blockName ) ) {
509
+ throw new Error(
510
+ `Existing compound child graph is invalid: ${ child.blockName } is declared more than once.`
511
+ );
512
+ }
513
+
514
+ seenKeys.add( child.key );
515
+ seenBlockNames.add( child.blockName );
516
+
517
+ if ( child.ancestorBlockNames.length === 0 ) {
518
+ if ( child.parentBlockNames.length === 0 ) {
519
+ throw new Error(
520
+ `Existing compound child graph is invalid: ${ child.blockName } must declare a parent block.`
521
+ );
522
+ }
523
+ continue;
524
+ }
525
+
526
+ for ( let index = 0; index < child.ancestorBlockNames.length; index += 1 ) {
527
+ const ancestorBlockName = child.ancestorBlockNames[ index ];
528
+ const ancestorChild = childByBlockName.get( ancestorBlockName );
529
+
530
+ if ( ! ancestorChild ) {
531
+ throw new Error(
532
+ `Existing compound child graph is invalid: ${ child.blockName } references missing ancestor ${ ancestorBlockName }.`
533
+ );
534
+ }
535
+
536
+ if ( ! ancestorChild.container ) {
537
+ throw new Error(
538
+ `Existing compound child graph is invalid: ${ ancestorChild.blockName } is not declared as a container child but has nested descendants.`
539
+ );
540
+ }
541
+
542
+ const expectedAncestorRoute = child.ancestorBlockNames.slice( 0, index );
543
+ const ancestorRouteMatches =
544
+ ancestorChild.ancestorBlockNames.length === expectedAncestorRoute.length &&
545
+ expectedAncestorRoute.every(
546
+ ( expectedAncestorBlockName, expectedIndex ) =>
547
+ ancestorChild.ancestorBlockNames[ expectedIndex ] ===
548
+ expectedAncestorBlockName
549
+ );
550
+
551
+ if ( ! ancestorRouteMatches ) {
552
+ throw new Error(
553
+ `Existing compound child graph is invalid: ${ ancestorChild.blockName } is not on the declared ancestor route for ${ child.blockName }.`
554
+ );
555
+ }
556
+
557
+ const nextBlockName =
558
+ child.ancestorBlockNames[ index + 1 ] ?? child.blockName;
559
+ if ( ancestorChild.directAllowedBlocks.includes( nextBlockName ) ) {
560
+ continue;
561
+ }
562
+
563
+ throw new Error(
564
+ `Existing compound child graph is invalid: ${ ancestorChild.blockName } does not currently allow ${ nextBlockName } as a direct child.`
565
+ );
566
+ }
567
+ }
568
+ }
569
+
570
+ function resolveExistingCompoundChild(
571
+ value: string,
572
+ existingChildren: ExistingCompoundChild[]
573
+ ): ExistingCompoundChild {
574
+ const trimmedValue = value.trim();
575
+ if ( trimmedValue.length === 0 ) {
576
+ throw new Error( 'Ancestor references must not be empty.' );
577
+ }
578
+
579
+ const normalizedCandidate = resolveValidatedBlockSlug(
580
+ trimmedValue.includes( '/' )
581
+ ? trimmedValue.slice( trimmedValue.lastIndexOf( '/' ) + 1 )
582
+ : trimmedValue
583
+ );
584
+ const resolved = existingChildren.find(
585
+ ( child ) =>
586
+ child.blockName === trimmedValue ||
587
+ child.folderSlug === trimmedValue ||
588
+ child.key === normalizedCandidate ||
589
+ child.folderSlug === `${ PARENT_BLOCK_SLUG }-${ normalizedCandidate }`
590
+ );
591
+
592
+ if ( ! resolved ) {
593
+ throw new Error(
594
+ `Unable to resolve compound child ancestor "${ value }". Use an existing child key, folder slug, or block name.`
595
+ );
596
+ }
597
+
598
+ return resolved;
599
+ }
600
+
601
+ function ensureUniqueAncestorChain(
602
+ ancestors: ExistingCompoundChild[]
603
+ ): ExistingCompoundChild[] {
604
+ const seenKeys = new Set< string >();
605
+
606
+ return ancestors.filter( ( ancestor ) => {
607
+ if ( seenKeys.has( ancestor.key ) ) {
608
+ return false;
609
+ }
610
+
611
+ seenKeys.add( ancestor.key );
612
+ return true;
613
+ } );
614
+ }
615
+
616
+ function validateAncestorChain(
617
+ ancestorChain: ExistingCompoundChild[]
618
+ ) {
619
+ for ( let index = 0; index < ancestorChain.length - 1; index += 1 ) {
620
+ const currentAncestor = ancestorChain[ index ];
621
+ const nextAncestor = ancestorChain[ index + 1 ];
622
+
623
+ if ( currentAncestor.directAllowedBlocks.includes( nextAncestor.blockName ) ) {
624
+ continue;
625
+ }
626
+
627
+ throw new Error(
628
+ `Invalid ancestor chain: ${ currentAncestor.blockName } does not currently allow ${ nextAncestor.blockName } as a direct child.`
629
+ );
630
+ }
631
+ }
632
+
633
+ function validateNestedAncestorTarget( ancestorChain: ExistingCompoundChild[] ) {
634
+ const directAncestor = ancestorChain[ ancestorChain.length - 1 ];
635
+
636
+ if ( ! directAncestor ) {
637
+ return;
638
+ }
639
+
640
+ if ( directAncestor.container ) {
641
+ return;
642
+ }
643
+
644
+ throw new Error(
645
+ `Cannot nest descendants under ${ directAncestor.blockName } because it is not declared as a container child. Re-add that child with --container or target an existing container child.`
646
+ );
647
+ }
648
+
649
+ function findCompoundChildSpecSource(
650
+ childrenRegistrySource: string,
651
+ childKey: string
652
+ ): string | null {
653
+ const childSpecPattern = /\{[\s\S]*?key:\s*["'][^"']+["'][\s\S]*?\n\s*\},/g;
654
+
655
+ for ( const match of childrenRegistrySource.matchAll( childSpecPattern ) ) {
656
+ const candidate = match[ 0 ];
657
+ if (
658
+ candidate.includes( `key: "${ childKey }"` ) ||
659
+ candidate.includes( `key: '${ childKey }'` )
660
+ ) {
661
+ return candidate;
662
+ }
663
+ }
664
+
665
+ return null;
666
+ }
667
+
668
+ function validateAncestorInstantiability(
669
+ childrenFile: string,
670
+ ancestorChain: ExistingCompoundChild[]
671
+ ) {
672
+ if ( ancestorChain.length === 0 ) {
673
+ return;
674
+ }
675
+
676
+ const childrenRegistrySource = fs.readFileSync( childrenFile, 'utf8' );
677
+
678
+ for ( const ancestor of ancestorChain ) {
679
+ const childSpecSource = findCompoundChildSpecSource(
680
+ childrenRegistrySource,
681
+ ancestor.key
682
+ );
683
+ if ( ! childSpecSource ) {
684
+ continue;
685
+ }
686
+
687
+ const supportsInserterMatch = childSpecSource.match(
688
+ /supportsInserter:\s*(true|false)/
689
+ );
690
+ const supportsInserter = supportsInserterMatch?.[ 1 ] === 'true';
691
+ const hasTemplateInstances = ! /templateInstances:\s*\[\s*\]/.test( childSpecSource );
692
+
693
+ if ( supportsInserter || hasTemplateInstances ) {
694
+ continue;
695
+ }
696
+
697
+ throw new Error(
698
+ `Cannot nest descendants under ${ ancestor.blockName } because it is hidden and has no seeded template instances. Re-add or promote that child with a visible inserter or a seeded template first.`
699
+ );
700
+ }
701
+ }
702
+
703
+ function formatProjectRelativePath( filePath: string ): string {
704
+ return path.relative( PROJECT_ROOT, filePath ) || '.';
705
+ }
706
+
707
+ function buildCompoundChildGraphNodes(
708
+ existingChildren: ExistingCompoundChild[],
709
+ prospectiveChild: CompoundChildGraphNode
710
+ ): CompoundChildGraphNode[] {
711
+ return [
712
+ ...existingChildren.map( ( child ) => ( {
713
+ blockName: child.blockName,
714
+ container: child.container,
715
+ directParentBlockName: resolveDirectParentBlockName( child ),
716
+ isProspective: false,
717
+ key: child.key,
718
+ placement: child.placement,
719
+ supportsInserter: child.supportsInserter,
720
+ title: child.title,
721
+ } ) ),
722
+ prospectiveChild,
723
+ ];
724
+ }
725
+
726
+ function renderCompoundChildGraphPreview(
727
+ graphNodes: CompoundChildGraphNode[],
728
+ plannedWritePaths: string[],
729
+ options: {
730
+ dryRun: boolean;
731
+ }
732
+ ): string {
733
+ const childrenByParent = new Map< string, CompoundChildGraphNode[] >();
734
+
735
+ for ( const graphNode of graphNodes ) {
736
+ const existingChildren = childrenByParent.get( graphNode.directParentBlockName ) ?? [];
737
+ existingChildren.push( graphNode );
738
+ childrenByParent.set( graphNode.directParentBlockName, existingChildren );
739
+ }
740
+
741
+ const renderChildLines = (
742
+ parentBlockName: string,
743
+ indent: string
744
+ ): string[] => {
745
+ const directChildren = [ ...( childrenByParent.get( parentBlockName ) ?? [] ) ].sort(
746
+ ( left, right ) => left.key.localeCompare( right.key )
747
+ );
748
+
749
+ return directChildren.flatMap( ( child ) => {
750
+ const labels = [
751
+ child.isProspective ? 'new' : 'existing',
752
+ child.placement === 'root' ? 'root' : 'nested',
753
+ child.container ? 'container' : 'leaf',
754
+ child.supportsInserter ? 'visible' : 'hidden',
755
+ ];
756
+ const line = `${ indent }- ${ child.key } -> ${ child.blockName } [${ labels.join( ', ' ) }]`;
757
+ return [ line, ...renderChildLines( child.blockName, `${ indent } ` ) ];
758
+ } );
759
+ };
760
+
761
+ return [
762
+ 'Compound child graph preview',
763
+ `Parent block: ${ PARENT_BLOCK_NAME }`,
764
+ ...renderChildLines( PARENT_BLOCK_NAME, '' ),
765
+ '',
766
+ 'Planned writes',
767
+ ...plannedWritePaths.map( ( plannedWritePath ) => `- ${ plannedWritePath }` ),
768
+ options.dryRun ? '' : '',
769
+ options.dryRun
770
+ ? 'Dry run only; no files were written.'
771
+ : 'Applying these updates now.',
772
+ ]
773
+ .filter( ( line, index, lines ) => {
774
+ if ( line.length > 0 ) {
775
+ return true;
776
+ }
777
+
778
+ return index > 0 && lines[ index - 1 ].length > 0;
779
+ } )
780
+ .join( '\n' );
781
+ }
782
+
783
+ function updateAllowedBlocks(
784
+ filePath: string,
785
+ blockName: string
786
+ ) {
787
+ const blockJson = readBlockJsonDocument( filePath );
788
+ const existingAllowedBlocks = Array.isArray( blockJson.allowedBlocks )
789
+ ? blockJson.allowedBlocks.filter(
790
+ ( value ): value is string => typeof value === 'string'
791
+ )
792
+ : [];
793
+
794
+ if ( existingAllowedBlocks.includes( blockName ) ) {
795
+ return;
796
+ }
797
+
798
+ blockJson.allowedBlocks = [ ...existingAllowedBlocks, blockName ];
799
+ writeBlockJsonDocument( filePath, blockJson );
800
+ }
801
+
303
802
  function renderBlockJson(
304
803
  childBlockName: string,
305
804
  childFolderSlug: string,
306
- childTitle: string
805
+ childTitle: string,
806
+ options: {
807
+ allowedBlocks?: string[];
808
+ ancestorBlockNames: string[];
809
+ container: boolean;
810
+ supportsInserter: boolean;
811
+ }
307
812
  ): string {
308
813
  const childCssClassName = buildBlockCssClassName( PARENT_BLOCK_NAMESPACE, childFolderSlug );
309
-
310
- return `${ JSON.stringify(
311
- {
312
- $schema: 'https://schemas.wp.org/trunk/block.json',
313
- apiVersion: 3,
314
- name: childBlockName,
315
- version: '{{blockMetadataVersion}}',
316
- title: childTitle,
317
- category: '{{compoundChildCategory}}',
318
- icon: '{{compoundChildIcon}}',
319
- description: `Internal item block used by ${ PARENT_BLOCK_TITLE }.`,
320
- parent: [ PARENT_BLOCK_NAME ],
321
- example: {},
322
- supports: {
323
- html: false,
324
- inserter: false,
325
- reusable: false,
814
+ const document: Record< string, unknown > = {
815
+ $schema: 'https://schemas.wp.org/trunk/block.json',
816
+ apiVersion: 3,
817
+ name: childBlockName,
818
+ version: '{{blockMetadataVersion}}',
819
+ title: childTitle,
820
+ category: '{{compoundChildCategory}}',
821
+ icon: '{{compoundChildIcon}}',
822
+ description: `Internal item block used by ${ PARENT_BLOCK_TITLE }.`,
823
+ example: {},
824
+ supports: {
825
+ html: false,
826
+ inserter: options.supportsInserter,
827
+ reusable: false,
828
+ },
829
+ attributes: {
830
+ title: {
831
+ type: 'string',
832
+ source: 'html',
833
+ selector: `.${ childCssClassName }__title`,
834
+ default: childTitle,
326
835
  },
327
- attributes: {
328
- title: {
329
- type: 'string',
330
- source: 'html',
331
- selector: `.${ childCssClassName }__title`,
332
- default: childTitle,
333
- },
334
- body: {
335
- type: 'string',
336
- source: 'html',
337
- selector: `.${ childCssClassName }__body`,
338
- default: CHILD_PLACEHOLDER,
339
- },
836
+ body: {
837
+ type: 'string',
838
+ source: 'html',
839
+ selector: `.${ childCssClassName }__body`,
840
+ default: CHILD_PLACEHOLDER,
340
841
  },
341
- textdomain: TEXT_DOMAIN,
342
- editorScript: 'file:./index.js',
343
842
  },
344
- null,
345
- '\t'
346
- ) }\n`;
843
+ textdomain: TEXT_DOMAIN,
844
+ editorScript: 'file:./index.js',
845
+ };
846
+
847
+ if ( options.ancestorBlockNames.length > 0 ) {
848
+ document.ancestor = options.ancestorBlockNames;
849
+ } else {
850
+ document.parent = [ PARENT_BLOCK_NAME ];
851
+ }
852
+
853
+ if ( options.container || ( options.allowedBlocks && options.allowedBlocks.length > 0 ) ) {
854
+ document.allowedBlocks = options.allowedBlocks ?? [];
855
+ }
856
+
857
+ return `${ JSON.stringify( document, null, '\t' ) }\n`;
347
858
  }
348
859
 
349
860
  function renderTypesFile(
@@ -491,10 +1002,15 @@ function renderEditFile(
491
1002
  const childCssClassName = buildBlockCssClassName( PARENT_BLOCK_NAMESPACE, childFolderSlug );
492
1003
 
493
1004
  return `import type { BlockEditProps } from '@wp-typia/block-types/blocks/registration';
494
- import { RichText, useBlockProps } from '@wordpress/block-editor';
1005
+ import { InnerBlocks, RichText, useBlockProps } from '@wordpress/block-editor';
495
1006
  import { Notice } from '@wordpress/components';
496
1007
  import { __ } from '@wordpress/i18n';
497
1008
 
1009
+ import metadata from './block-metadata';
1010
+ import {
1011
+ \tgetChildInnerBlocksPropsOptions,
1012
+ \thasNestedChildBlocks,
1013
+ } from '../${ PARENT_BLOCK_SLUG }/children';
498
1014
  import { useTypiaValidation } from './hooks';
499
1015
  import type { ${ childTypeName } } from './types';
500
1016
  import {
@@ -503,6 +1019,14 @@ import {
503
1019
  } from './validators';
504
1020
 
505
1021
  type EditProps = BlockEditProps< ${ childTypeName } >;
1022
+ type CompoundInnerBlocksProps = Parameters< typeof InnerBlocks >[ 0 ] & {
1023
+ \tdefaultBlock?: [ string, Record< string, unknown > ];
1024
+ \tdirectInsert?: boolean;
1025
+ };
1026
+
1027
+ const TypedInnerBlocks = InnerBlocks as unknown as (
1028
+ \tprops: CompoundInnerBlocksProps
1029
+ ) => ReturnType< typeof InnerBlocks >;
506
1030
 
507
1031
  export default function Edit( {
508
1032
  \tattributes,
@@ -513,6 +1037,10 @@ export default function Edit( {
513
1037
  \t\tattributes,
514
1038
  \t\tvalidate${ childInterfaceName }
515
1039
  \t);
1040
+ \tconst nestedInnerBlocksPropsOptions = getChildInnerBlocksPropsOptions(
1041
+ \t\tmetadata.name
1042
+ \t);
1043
+ \tconst showsNestedChildren = hasNestedChildBlocks( metadata.name );
516
1044
 
517
1045
  \treturn (
518
1046
  \t\t<div { ...useBlockProps( { className: '${ childCssClassName }' } ) }>
@@ -539,6 +1067,13 @@ export default function Edit( {
539
1067
  \t\t\t\t\t</ul>
540
1068
  \t\t\t\t</Notice>
541
1069
  \t\t\t) }
1070
+ \t\t\t{ showsNestedChildren && (
1071
+ \t\t\t\t<div className="${ childCssClassName }__children">
1072
+ \t\t\t\t\t<TypedInnerBlocks
1073
+ \t\t\t\t\t\t{ ...( nestedInnerBlocksPropsOptions ?? {} ) }
1074
+ \t\t\t\t\t/>
1075
+ \t\t\t\t</div>
1076
+ \t\t\t) }
542
1077
  \t\t</div>
543
1078
  \t);
544
1079
  }
@@ -548,8 +1083,10 @@ export default function Edit( {
548
1083
  function renderSaveFile( childFolderSlug: string, childTypeName: string ): string {
549
1084
  const childCssClassName = buildBlockCssClassName( PARENT_BLOCK_NAMESPACE, childFolderSlug );
550
1085
 
551
- return `import { RichText, useBlockProps } from '@wordpress/block-editor';
1086
+ return `import { InnerBlocks, RichText, useBlockProps } from '@wordpress/block-editor';
552
1087
 
1088
+ import metadata from './block-metadata';
1089
+ import { hasNestedChildBlocks } from '../${ PARENT_BLOCK_SLUG }/children';
553
1090
  import type { ${ childTypeName } } from './types';
554
1091
 
555
1092
  export default function Save( {
@@ -557,6 +1094,8 @@ export default function Save( {
557
1094
  }: {
558
1095
  \tattributes: ${ childTypeName };
559
1096
  } ) {
1097
+ \tconst showsNestedChildren = hasNestedChildBlocks( metadata.name );
1098
+
560
1099
  \treturn (
561
1100
  \t\t<div { ...useBlockProps.save( { className: '${ childCssClassName }' } ) }>
562
1101
  \t\t\t<RichText.Content
@@ -569,13 +1108,18 @@ export default function Save( {
569
1108
  \t\t\t\tclassName="${ childCssClassName }__body"
570
1109
  \t\t\t\tvalue={ attributes.body }
571
1110
  \t\t\t/>
1111
+ \t\t\t{ showsNestedChildren && (
1112
+ \t\t\t\t<div className="${ childCssClassName }__children">
1113
+ \t\t\t\t\t<InnerBlocks.Content />
1114
+ \t\t\t\t</div>
1115
+ \t\t\t) }
572
1116
  \t\t</div>
573
1117
  \t);
574
1118
  }
575
1119
  `;
576
1120
  }
577
1121
 
578
- function renderIndexFile( childTypeName: string, childFolderSlug: string ): string {
1122
+ function renderIndexFile( childTypeName: string ): string {
579
1123
  return `import {
580
1124
  \tregisterScaffoldBlockType,
581
1125
  \ttype BlockConfiguration,
@@ -604,14 +1148,70 @@ registerScaffoldBlockType( registration.name, registration.settings );
604
1148
  `;
605
1149
  }
606
1150
 
1151
+ function renderChildSpecLines(
1152
+ options: {
1153
+ ancestorKeys: string[];
1154
+ blockName: string;
1155
+ container: boolean;
1156
+ folderSlug: string;
1157
+ key: string;
1158
+ placement: 'nested' | 'root';
1159
+ seedTemplate: boolean;
1160
+ supportsInserter: boolean;
1161
+ title: string;
1162
+ }
1163
+ ): string[] {
1164
+ const templateLines =
1165
+ options.seedTemplate
1166
+ ? [
1167
+ '\ttemplateInstances: [',
1168
+ '\t\t{',
1169
+ `\t\t\tbody: ${ JSON.stringify( CHILD_PLACEHOLDER ) },`,
1170
+ `\t\t\ttitle: ${ JSON.stringify( options.title ) },`,
1171
+ '\t\t},',
1172
+ '\t],',
1173
+ ]
1174
+ : [ '\ttemplateInstances: [],' ];
1175
+
1176
+ return [
1177
+ '{',
1178
+ `\tancestorKeys: [ ${ options.ancestorKeys.map( ( value ) => JSON.stringify( value ) ).join( ', ' ) } ],`,
1179
+ `\tblockName: ${ JSON.stringify( options.blockName ) },`,
1180
+ `\tbodyPlaceholder: ${ JSON.stringify( CHILD_PLACEHOLDER ) },`,
1181
+ `\tcontainer: ${ options.container ? 'true' : 'false' },`,
1182
+ `\tfolderSlug: ${ JSON.stringify( options.folderSlug ) },`,
1183
+ `\tkey: ${ JSON.stringify( options.key ) },`,
1184
+ `\tplacement: ${ JSON.stringify( options.placement ) },`,
1185
+ `\tsupportsInserter: ${ options.supportsInserter ? 'true' : 'false' },`,
1186
+ ...templateLines,
1187
+ `\ttitle: ${ JSON.stringify( options.title ) },`,
1188
+ '},',
1189
+ ];
1190
+ }
1191
+
607
1192
  function main() {
608
- const { slug, title } = parseArgs();
1193
+ const {
1194
+ ancestorValues,
1195
+ container,
1196
+ dryRun,
1197
+ inserter,
1198
+ slug,
1199
+ title,
1200
+ } = parseArgs();
609
1201
  const normalizedSlug = slug ? resolveValidatedBlockSlug( slug ) : '';
610
1202
 
611
1203
  if ( normalizedSlug.length === 0 ) {
612
1204
  throw new Error( 'Use a child slug with lowercase letters, numbers, and hyphens only.' );
613
1205
  }
614
1206
 
1207
+ const existingChildren = listExistingCompoundChildren();
1208
+ validateExistingCompoundChildGraph( existingChildren );
1209
+ const ancestorChain = ensureUniqueAncestorChain(
1210
+ ancestorValues.map( ( value ) =>
1211
+ resolveExistingCompoundChild( value, existingChildren )
1212
+ )
1213
+ );
1214
+ validateAncestorChain( ancestorChain );
615
1215
  const childTitle = title?.trim().length ? title.trim() : toTitleCase( normalizedSlug );
616
1216
  const childFolderSlug = `${ PARENT_BLOCK_SLUG }-${ normalizedSlug }`;
617
1217
  const childBlockName = `${ PARENT_BLOCK_NAME }-${ normalizedSlug }`;
@@ -628,6 +1228,10 @@ function main() {
628
1228
  );
629
1229
  const blockConfigFile = path.join( PROJECT_ROOT, 'scripts', 'block-config.ts' );
630
1230
 
1231
+ if ( ancestorChain.some( ( ancestor ) => ancestor.key === normalizedSlug ) ) {
1232
+ throw new Error( 'A child block cannot list itself as an ancestor.' );
1233
+ }
1234
+
631
1235
  if ( fs.existsSync( childDir ) ) {
632
1236
  throw new Error( `Child block already exists: ${ childFolderSlug }` );
633
1237
  }
@@ -638,11 +1242,69 @@ function main() {
638
1242
  );
639
1243
  }
640
1244
 
1245
+ validateAncestorInstantiability( childrenFile, ancestorChain );
1246
+ validateNestedAncestorTarget( ancestorChain );
1247
+ const supportsInserter =
1248
+ inserter === 'visible'
1249
+ ? true
1250
+ : inserter === 'hidden'
1251
+ ? false
1252
+ : container || ancestorChain.length > 0;
1253
+ const placement = ancestorChain.length > 0 ? 'nested' : 'root';
1254
+ const seedTemplate = supportsInserter || container || ancestorChain.length > 0;
1255
+ const directAllowedBlocks: string[] = [];
1256
+ const directAncestor = ancestorChain[ ancestorChain.length - 1 ];
1257
+ const plannedWritePaths = [
1258
+ formatProjectRelativePath( path.join( childDir, 'block.json' ) ),
1259
+ formatProjectRelativePath( path.join( childDir, 'types.ts' ) ),
1260
+ formatProjectRelativePath( path.join( childDir, 'typia.manifest.json' ) ),
1261
+ formatProjectRelativePath( path.join( childDir, 'block-metadata.ts' ) ),
1262
+ formatProjectRelativePath( path.join( childDir, 'manifest-document.ts' ) ),
1263
+ formatProjectRelativePath( path.join( childDir, 'manifest-defaults-document.ts' ) ),
1264
+ formatProjectRelativePath( path.join( childDir, 'hooks.ts' ) ),
1265
+ formatProjectRelativePath( path.join( childDir, 'validators.ts' ) ),
1266
+ formatProjectRelativePath( path.join( childDir, 'edit.tsx' ) ),
1267
+ formatProjectRelativePath( path.join( childDir, 'save.tsx' ) ),
1268
+ formatProjectRelativePath( path.join( childDir, 'index.tsx' ) ),
1269
+ formatProjectRelativePath( childrenFile ),
1270
+ formatProjectRelativePath( blockConfigFile ),
1271
+ formatProjectRelativePath(
1272
+ placement === 'root' ? PARENT_BLOCK_JSON_PATH : directAncestor.blockJsonPath
1273
+ ),
1274
+ ];
1275
+
1276
+ console.log(
1277
+ renderCompoundChildGraphPreview(
1278
+ buildCompoundChildGraphNodes( existingChildren, {
1279
+ blockName: childBlockName,
1280
+ container,
1281
+ directParentBlockName:
1282
+ directAncestor?.blockName ?? PARENT_BLOCK_NAME,
1283
+ isProspective: true,
1284
+ key: normalizedSlug,
1285
+ placement,
1286
+ supportsInserter,
1287
+ title: childTitle,
1288
+ } ),
1289
+ plannedWritePaths,
1290
+ { dryRun }
1291
+ )
1292
+ );
1293
+
1294
+ if ( dryRun ) {
1295
+ return;
1296
+ }
1297
+
641
1298
  fs.mkdirSync( childDir, { recursive: true } );
642
1299
 
643
1300
  fs.writeFileSync(
644
1301
  path.join( childDir, 'block.json' ),
645
- renderBlockJson( childBlockName, childFolderSlug, childTitle ),
1302
+ renderBlockJson( childBlockName, childFolderSlug, childTitle, {
1303
+ allowedBlocks: directAllowedBlocks,
1304
+ ancestorBlockNames: ancestorChain.map( ( ancestor ) => ancestor.blockName ),
1305
+ container,
1306
+ supportsInserter,
1307
+ } ),
646
1308
  'utf8'
647
1309
  );
648
1310
  fs.writeFileSync(
@@ -688,14 +1350,24 @@ function main() {
688
1350
  );
689
1351
  fs.writeFileSync(
690
1352
  path.join( childDir, 'index.tsx' ),
691
- renderIndexFile( childTypeName, childFolderSlug ),
1353
+ renderIndexFile( childTypeName ),
692
1354
  'utf8'
693
1355
  );
694
1356
 
695
1357
  insertBeforeMarker(
696
1358
  childrenFile,
697
- ALLOWED_CHILD_MARKER,
698
- [ `'${ childBlockName }',` ]
1359
+ CHILD_SPEC_MARKER,
1360
+ renderChildSpecLines( {
1361
+ ancestorKeys: ancestorChain.map( ( ancestor ) => ancestor.key ),
1362
+ blockName: childBlockName,
1363
+ container,
1364
+ folderSlug: childFolderSlug,
1365
+ key: normalizedSlug,
1366
+ placement,
1367
+ seedTemplate,
1368
+ supportsInserter,
1369
+ title: childTitle,
1370
+ } )
699
1371
  );
700
1372
  insertBeforeMarker(
701
1373
  blockConfigFile,
@@ -709,6 +1381,13 @@ function main() {
709
1381
  ]
710
1382
  );
711
1383
 
1384
+ if ( placement === 'root' ) {
1385
+ updateAllowedBlocks( PARENT_BLOCK_JSON_PATH, childBlockName );
1386
+ } else {
1387
+ const directAncestor = ancestorChain[ ancestorChain.length - 1 ];
1388
+ updateAllowedBlocks( directAncestor.blockJsonPath, childBlockName );
1389
+ }
1390
+
712
1391
  console.log( `✅ Added compound child block ${ childBlockName }` );
713
1392
  console.log(
714
1393
  'Run `sync-types` next to generate block.json metadata, manifests, schemas, and PHP validators for the new child block.'