@wp-typia/project-tools 0.16.8 → 0.16.10

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 (50) hide show
  1. package/README.md +14 -4
  2. package/dist/runtime/block-generator-service.d.ts +5 -1
  3. package/dist/runtime/block-generator-service.js +7 -3
  4. package/dist/runtime/built-in-block-artifacts.js +388 -572
  5. package/dist/runtime/built-in-block-code-artifacts.js +96 -46
  6. package/dist/runtime/built-in-block-code-templates.d.ts +36 -0
  7. package/dist/runtime/built-in-block-code-templates.js +2234 -0
  8. package/dist/runtime/cli-add-block.d.ts +2 -1
  9. package/dist/runtime/cli-add-block.js +163 -25
  10. package/dist/runtime/cli-add-shared.d.ts +7 -0
  11. package/dist/runtime/cli-add-shared.js +4 -6
  12. package/dist/runtime/cli-add-workspace.js +56 -17
  13. package/dist/runtime/cli-core.d.ts +4 -0
  14. package/dist/runtime/cli-core.js +3 -0
  15. package/dist/runtime/cli-diagnostics.d.ts +58 -0
  16. package/dist/runtime/cli-diagnostics.js +101 -0
  17. package/dist/runtime/cli-doctor.d.ts +2 -1
  18. package/dist/runtime/cli-doctor.js +16 -5
  19. package/dist/runtime/cli-help.js +4 -4
  20. package/dist/runtime/cli-scaffold.d.ts +5 -1
  21. package/dist/runtime/cli-scaffold.js +138 -111
  22. package/dist/runtime/external-layer-selection.d.ts +14 -0
  23. package/dist/runtime/external-layer-selection.js +35 -0
  24. package/dist/runtime/index.d.ts +2 -2
  25. package/dist/runtime/index.js +1 -1
  26. package/dist/runtime/migration-render.d.ts +23 -1
  27. package/dist/runtime/migration-render.js +58 -10
  28. package/dist/runtime/migration-ui-capability.js +17 -8
  29. package/dist/runtime/migration-utils.d.ts +7 -6
  30. package/dist/runtime/migration-utils.js +76 -73
  31. package/dist/runtime/migrations.js +2 -2
  32. package/dist/runtime/object-utils.d.ts +8 -1
  33. package/dist/runtime/object-utils.js +21 -1
  34. package/dist/runtime/scaffold-apply-utils.d.ts +14 -2
  35. package/dist/runtime/scaffold-apply-utils.js +19 -6
  36. package/dist/runtime/scaffold-repository-reference.d.ts +22 -0
  37. package/dist/runtime/scaffold-repository-reference.js +119 -0
  38. package/dist/runtime/scaffold.d.ts +5 -1
  39. package/dist/runtime/scaffold.js +15 -37
  40. package/dist/runtime/template-layers.d.ts +6 -0
  41. package/dist/runtime/template-layers.js +20 -7
  42. package/dist/runtime/template-render.d.ts +13 -2
  43. package/dist/runtime/template-render.js +102 -71
  44. package/dist/runtime/template-source.d.ts +6 -5
  45. package/dist/runtime/template-source.js +284 -217
  46. package/package.json +8 -3
  47. package/templates/_shared/base/src/validator-toolkit.ts.mustache +2 -2
  48. package/templates/_shared/compound/core/scripts/add-compound-child.ts.mustache +61 -16
  49. package/templates/_shared/migration-ui/common/src/migrations/helpers.ts +19 -47
  50. package/templates/_shared/migration-ui/common/src/migrations/index.ts +40 -11
@@ -0,0 +1,2234 @@
1
+ /**
2
+ * Built-in emitter template bodies grouped away from artifact assembly so
3
+ * authoring stays focused on the emitted TS/TSX/PHP sources themselves.
4
+ */
5
+ export const SHARED_HOOKS_TEMPLATE = `import { useMemo } from '@wordpress/element';
6
+
7
+ import {
8
+ createUseTypiaValidationHook,
9
+ formatValidationError,
10
+ formatValidationErrors,
11
+ } from '@wp-typia/block-runtime/validation';
12
+
13
+ export {
14
+ formatValidationError,
15
+ formatValidationErrors,
16
+ type TypiaValidationError,
17
+ type ValidationResult,
18
+ type ValidationState,
19
+ } from '@wp-typia/block-runtime/validation';
20
+
21
+ export const useTypiaValidation = createUseTypiaValidationHook( {
22
+ useMemo,
23
+ } );
24
+ `;
25
+ export const BLOCK_METADATA_WRAPPER_TEMPLATE = `import rawMetadata from './block.json';
26
+ import { defineScaffoldBlockMetadata } from '@wp-typia/block-runtime/blocks';
27
+
28
+ const metadata = defineScaffoldBlockMetadata(rawMetadata);
29
+
30
+ export default metadata;
31
+ `;
32
+ export const MANIFEST_DOCUMENT_WRAPPER_TEMPLATE = `import rawCurrentManifest from './typia.manifest.json';
33
+ import { defineManifestDocument } from '@wp-typia/block-runtime/editor';
34
+
35
+ const currentManifest = defineManifestDocument(rawCurrentManifest);
36
+
37
+ export default currentManifest;
38
+ `;
39
+ export const MANIFEST_DEFAULTS_DOCUMENT_WRAPPER_TEMPLATE = `import rawCurrentManifest from './typia.manifest.json';
40
+ import { defineManifestDefaultsDocument } from '@wp-typia/block-runtime/defaults';
41
+
42
+ const currentManifest = defineManifestDefaultsDocument(rawCurrentManifest);
43
+
44
+ export default currentManifest;
45
+ `;
46
+ export const BASIC_EDIT_TEMPLATE = `/**
47
+ * Editor component for {{title}} Block
48
+ */
49
+
50
+ import type { BlockEditProps } from '@wp-typia/block-types/blocks/registration';
51
+ import {
52
+ InspectorControls,
53
+ RichText,
54
+ useBlockProps,
55
+ } from '@wordpress/block-editor';
56
+ import { Notice, PanelBody, TextControl } from '@wordpress/components';
57
+ import { __ } from '@wordpress/i18n';
58
+ import currentManifest from './manifest-document';
59
+ import {
60
+ InspectorFromManifest,
61
+ useEditorFields,
62
+ useTypedAttributeUpdater,
63
+ } from '@wp-typia/block-runtime/inspector';
64
+ import { {{pascalCase}}Attributes } from './types';
65
+ import {
66
+ sanitize{{pascalCase}}Attributes,
67
+ validate{{pascalCase}}Attributes,
68
+ } from './validators';
69
+ import { useTypiaValidation } from './hooks';
70
+
71
+ type EditProps = BlockEditProps<{{pascalCase}}Attributes>;
72
+
73
+ const validationErrorItemStyle = { color: '#cc1818', fontSize: '12px' };
74
+ const validationListStyle = { margin: 0, paddingLeft: '1em' };
75
+
76
+ function Edit({ attributes, setAttributes }: EditProps) {
77
+ const isVisible = attributes.isVisible !== false;
78
+ const blockProps = useBlockProps({
79
+ className: \`{{cssClassName}}\${isVisible ? '' : ' is-hidden'}\`,
80
+ });
81
+ const editorFields = useEditorFields(currentManifest, {
82
+ hidden: ['id', 'schemaVersion'],
83
+ manual: ['content'],
84
+ labels: {
85
+ alignment: __('Alignment', '{{textDomain}}'),
86
+ className: __('CSS Class', '{{textDomain}}'),
87
+ content: __('Content', '{{textDomain}}'),
88
+ isVisible: __('Visible', '{{textDomain}}'),
89
+ },
90
+ });
91
+ const classNameField = editorFields.getField('className');
92
+ const { errorMessages, isValid } = useTypiaValidation(
93
+ attributes,
94
+ validate{{pascalCase}}Attributes
95
+ );
96
+ const validateEditorUpdate = (nextAttributes: {{pascalCase}}Attributes) => {
97
+ try {
98
+ return {
99
+ data: sanitize{{pascalCase}}Attributes(nextAttributes),
100
+ errors: [],
101
+ isValid: true as const,
102
+ };
103
+ } catch {
104
+ return validate{{pascalCase}}Attributes(nextAttributes);
105
+ }
106
+ };
107
+ const { updateField } = useTypedAttributeUpdater(
108
+ attributes,
109
+ setAttributes,
110
+ validateEditorUpdate
111
+ );
112
+
113
+ return (
114
+ <>
115
+ <InspectorControls>
116
+ <InspectorFromManifest
117
+ attributes={attributes}
118
+ fieldLookup={editorFields}
119
+ onChange={updateField}
120
+ paths={['alignment', 'isVisible']}
121
+ title={__('Settings', '{{textDomain}}')}
122
+ >
123
+ <TextControl
124
+ label={__('Content', '{{textDomain}}')}
125
+ value={attributes.content || ''}
126
+ onChange={(value) => updateField('content', value)}
127
+ help={__('Mirrors the main block content.', '{{textDomain}}')}
128
+ />
129
+
130
+ <TextControl
131
+ label={classNameField?.label || __('CSS Class', '{{textDomain}}')}
132
+ value={attributes.className || ''}
133
+ onChange={(value) => updateField('className', value)}
134
+ help={__('Add an optional CSS class name.', '{{textDomain}}')}
135
+ />
136
+ </InspectorFromManifest>
137
+
138
+ {!isValid && (
139
+ <PanelBody title={__('Validation Errors', '{{textDomain}}')} initialOpen>
140
+ {errorMessages.map((error, index) => (
141
+ <div key={index} style={validationErrorItemStyle}>
142
+ • {error}
143
+ </div>
144
+ ))}
145
+ </PanelBody>
146
+ )}
147
+ </InspectorControls>
148
+
149
+ <div {...blockProps}>
150
+ <div className="{{cssClassName}}__content">
151
+ <RichText
152
+ tagName="p"
153
+ value={attributes.content || ''}
154
+ onChange={(value) => updateField('content', value)}
155
+ placeholder={__('Add your content...', '{{textDomain}}')}
156
+ />
157
+ </div>
158
+ {!isValid && (
159
+ <Notice status="error" isDismissible={false}>
160
+ <p>
161
+ <strong>{__('Validation Errors', '{{textDomain}}')}</strong>
162
+ </p>
163
+ <ul style={validationListStyle}>
164
+ {errorMessages.map((error, index) => (
165
+ <li key={index}>{error}</li>
166
+ ))}
167
+ </ul>
168
+ </Notice>
169
+ )}
170
+ </div>
171
+ </>
172
+ );
173
+ }
174
+
175
+ export default Edit;
176
+ `;
177
+ export const BASIC_SAVE_TEMPLATE = `/**
178
+ * Save/Frontend component for {{title}} Block
179
+ */
180
+
181
+ import { RichText, useBlockProps } from '@wordpress/block-editor';
182
+ import { {{pascalCase}}Attributes } from './types';
183
+
184
+ interface SaveProps {
185
+ attributes: {{pascalCase}}Attributes;
186
+ }
187
+
188
+ export default function Save({ attributes }: SaveProps) {
189
+ const isVisible = attributes.isVisible !== false;
190
+ const blockProps = useBlockProps.save({
191
+ className: \`{{cssClassName}}\${isVisible ? '' : ' is-hidden'}\`,
192
+ hidden: isVisible ? undefined : true,
193
+ 'aria-hidden': isVisible ? undefined : 'true',
194
+ });
195
+
196
+ return (
197
+ <div {...blockProps}>
198
+ <div
199
+ className="{{cssClassName}}__content"
200
+ data-align={attributes.alignment || 'left'}
201
+ >
202
+ <RichText.Content tagName="p" value={attributes.content} />
203
+ </div>
204
+ </div>
205
+ );
206
+ }
207
+ `;
208
+ export const BASIC_INDEX_TEMPLATE = `/**
209
+ * WordPress {{title}} Block
210
+ *
211
+ * Typia-powered type-safe block
212
+ */
213
+
214
+ import {
215
+ registerScaffoldBlockType,
216
+ type BlockConfiguration,
217
+ } from '@wp-typia/block-types/blocks/registration';
218
+ import type { BlockSupports } from '@wp-typia/block-types/blocks/supports';
219
+ import { __ } from '@wordpress/i18n';
220
+ import {
221
+ buildScaffoldBlockRegistration,
222
+ parseScaffoldBlockMetadata,
223
+ } from '@wp-typia/block-runtime/blocks';
224
+
225
+ // Import components
226
+ import Edit from './edit';
227
+ import Save from './save';
228
+ import metadata from './block-metadata';
229
+ import './editor.scss';
230
+ import './style.scss';
231
+
232
+ // Import types
233
+ import { {{pascalCase}}Attributes } from './types';
234
+ import { validators } from './validators';
235
+
236
+ const scaffoldSupports = {
237
+ html: false,
238
+ multiple: true,
239
+ align: ['wide', 'full'],
240
+ } satisfies BlockSupports;
241
+
242
+ // Register the block
243
+ const registration = buildScaffoldBlockRegistration(
244
+ parseScaffoldBlockMetadata<BlockConfiguration<{{pascalCase}}Attributes>>(metadata),
245
+ {
246
+ supports: scaffoldSupports,
247
+ example: {
248
+ attributes: validators.random(),
249
+ },
250
+ edit: Edit,
251
+ save: Save,
252
+ }
253
+ );
254
+
255
+ registerScaffoldBlockType(registration.name, registration.settings);
256
+ `;
257
+ export const BASIC_VALIDATORS_TEMPLATE = `import typia from 'typia';
258
+ import currentManifest from "./manifest-defaults-document";
259
+ import { {{pascalCase}}Attributes, {{pascalCase}}ValidationResult } from "./types";
260
+ import { generateBlockId } from "@wp-typia/block-runtime/identifiers";
261
+ import { createTemplateValidatorToolkit } from "./validator-toolkit";
262
+
263
+ const scaffoldValidators = createTemplateValidatorToolkit<{{pascalCase}}Attributes>({
264
+ assert: typia.createAssert<{{pascalCase}}Attributes>(),
265
+ clone: typia.misc.createClone<{{pascalCase}}Attributes>() as (
266
+ value: {{pascalCase}}Attributes,
267
+ ) => {{pascalCase}}Attributes,
268
+ is: typia.createIs<{{pascalCase}}Attributes>(),
269
+ manifest: currentManifest,
270
+ prune: typia.misc.createPrune<{{pascalCase}}Attributes>(),
271
+ random: typia.createRandom<{{pascalCase}}Attributes>() as (
272
+ ...args: unknown[]
273
+ ) => {{pascalCase}}Attributes,
274
+ finalize: (normalized) => ({
275
+ ...normalized,
276
+ id: normalized.id && normalized.id.length > 0 ? normalized.id : generateBlockId(),
277
+ }),
278
+ validate: typia.createValidate<{{pascalCase}}Attributes>(),
279
+ });
280
+
281
+ export const validate{{pascalCase}}Attributes =
282
+ scaffoldValidators.validateAttributes as (
283
+ attributes: unknown,
284
+ ) => {{pascalCase}}ValidationResult;
285
+
286
+ export const validators = scaffoldValidators.validators;
287
+
288
+ export const sanitize{{pascalCase}}Attributes =
289
+ scaffoldValidators.sanitizeAttributes as (
290
+ attributes: Partial<{{pascalCase}}Attributes>,
291
+ ) => {{pascalCase}}Attributes;
292
+
293
+ export const createAttributeUpdater = scaffoldValidators.createAttributeUpdater;
294
+ `;
295
+ export const INTERACTIVITY_EDIT_TEMPLATE = `import type { BlockEditProps } from '@wp-typia/block-types/blocks/registration';
296
+ import { __ } from '@wordpress/i18n';
297
+ import { useBlockProps, InspectorControls, RichText, BlockControls, AlignmentToolbar } from '@wordpress/block-editor';
298
+ import { PanelBody, RangeControl, Button, Notice } from '@wordpress/components';
299
+ import { useState } from '@wordpress/element';
300
+ import currentManifest from './manifest-document';
301
+ import {
302
+ InspectorFromManifest,
303
+ useEditorFields,
304
+ useTypedAttributeUpdater,
305
+ } from '@wp-typia/block-runtime/inspector';
306
+ import type { {{pascalCase}}Attributes } from './types';
307
+ import {
308
+ sanitize{{pascalCase}}Attributes,
309
+ validate{{pascalCase}}Attributes,
310
+ } from './validators';
311
+ import { useTypiaValidation } from './hooks';
312
+
313
+ type EditProps = BlockEditProps<{{pascalCase}}Attributes>;
314
+
315
+ const actionButtonRowStyle = { display: 'flex', gap: '8px', marginTop: '16px' };
316
+ const validationListStyle = { margin: 0, paddingLeft: '1em' };
317
+
318
+ export default function Edit({ attributes, setAttributes, isSelected }: EditProps) {
319
+ const [isPreviewing, setIsPreviewing] = useState(false);
320
+ const editorFields = useEditorFields(currentManifest, {
321
+ manual: ['content', 'clickCount', 'maxClicks'],
322
+ labels: {
323
+ alignment: __('Alignment', '{{textDomain}}'),
324
+ animation: __('Animation', '{{textDomain}}'),
325
+ interactiveMode: __('Interactive Mode', '{{textDomain}}'),
326
+ isVisible: __('Visible', '{{textDomain}}'),
327
+ showCounter: __('Show Counter', '{{textDomain}}'),
328
+ },
329
+ });
330
+ const { errorMessages, isValid } = useTypiaValidation(
331
+ attributes,
332
+ validate{{pascalCase}}Attributes,
333
+ );
334
+ const validateEditorUpdate = (nextAttributes: {{pascalCase}}Attributes) => {
335
+ try {
336
+ return {
337
+ data: sanitize{{pascalCase}}Attributes(nextAttributes),
338
+ errors: [],
339
+ isValid: true as const,
340
+ };
341
+ } catch {
342
+ return validate{{pascalCase}}Attributes(nextAttributes);
343
+ }
344
+ };
345
+ const { updateField } = useTypedAttributeUpdater(
346
+ attributes,
347
+ setAttributes,
348
+ validateEditorUpdate
349
+ );
350
+ const alignmentValue = editorFields.getStringValue(
351
+ attributes,
352
+ 'alignment',
353
+ 'left'
354
+ ) as NonNullable<{{pascalCase}}Attributes['alignment']>;
355
+ const clickCount = attributes.clickCount ?? 0;
356
+ const isVisible = editorFields.getBooleanValue(
357
+ attributes,
358
+ 'isVisible',
359
+ true
360
+ );
361
+ const isAnimating = attributes.isAnimating ?? false;
362
+ const maxClicks = attributes.maxClicks ?? 0;
363
+ const showCounter = editorFields.getBooleanValue(
364
+ attributes,
365
+ 'showCounter',
366
+ true
367
+ );
368
+ const interactiveMode = editorFields.getStringValue(
369
+ attributes,
370
+ 'interactiveMode',
371
+ 'click'
372
+ ) as NonNullable<{{pascalCase}}Attributes['interactiveMode']>;
373
+ const animation = editorFields.getStringValue(
374
+ attributes,
375
+ 'animation',
376
+ 'none'
377
+ ) as NonNullable<{{pascalCase}}Attributes['animation']>;
378
+
379
+ const blockProps = useBlockProps({
380
+ className: \`{{cssClassName}} {{cssClassName}}--\${interactiveMode}\`,
381
+ 'data-wp-interactive': '{{slugKebabCase}}',
382
+ 'data-wp-context': JSON.stringify({
383
+ clicks: clickCount,
384
+ isAnimating,
385
+ isVisible,
386
+ animation,
387
+ maxClicks,
388
+ })
389
+ });
390
+ const previewContentStyle = { textAlign: alignmentValue };
391
+ const progressBarStyle = { width: \`\${(clickCount / maxClicks) * 100}%\` };
392
+
393
+ const resetCounter = () => {
394
+ updateField('clickCount', 0);
395
+ updateField('isAnimating', false);
396
+ };
397
+
398
+ const testAnimation = () => {
399
+ updateField('isAnimating', true);
400
+ setTimeout(() => {
401
+ updateField('isAnimating', false);
402
+ }, 1000);
403
+ };
404
+
405
+ return (
406
+ <>
407
+ <BlockControls>
408
+ <AlignmentToolbar
409
+ value={alignmentValue}
410
+ onChange={(value) => updateField('alignment', (value || alignmentValue) as NonNullable<{{pascalCase}}Attributes['alignment']>)}
411
+ />
412
+ </BlockControls>
413
+
414
+ <InspectorControls>
415
+ <InspectorFromManifest
416
+ attributes={attributes}
417
+ fieldLookup={editorFields}
418
+ onChange={updateField}
419
+ paths={['alignment', 'interactiveMode', 'animation', 'showCounter', 'isVisible']}
420
+ title={__('Interactive Settings', '{{textDomain}}')}
421
+ />
422
+
423
+ <PanelBody title={__('Counter Settings', '{{textDomain}}')}>
424
+ <RangeControl
425
+ label={__('Max Clicks', '{{textDomain}}')}
426
+ value={maxClicks}
427
+ onChange={(value) => updateField('maxClicks', value ?? maxClicks)}
428
+ min={0}
429
+ max={100}
430
+ help={__('Set to 0 for unlimited clicks', '{{textDomain}}')}
431
+ />
432
+
433
+ <div style={actionButtonRowStyle}>
434
+ <Button
435
+ variant="secondary"
436
+ onClick={resetCounter}
437
+ isSmall
438
+ >
439
+ {__('Reset Counter', '{{textDomain}}')}
440
+ </Button>
441
+ <Button
442
+ variant="secondary"
443
+ onClick={testAnimation}
444
+ isSmall
445
+ >
446
+ {__('Test Animation', '{{textDomain}}')}
447
+ </Button>
448
+ </div>
449
+ </PanelBody>
450
+
451
+ {!isValid && (
452
+ <PanelBody title={__('Validation Errors', '{{textDomain}}')} initialOpen>
453
+ {errorMessages.map((error, index) => (
454
+ <Notice key={index} status="error" isDismissible={false}>
455
+ {error}
456
+ </Notice>
457
+ ))}
458
+ </PanelBody>
459
+ )}
460
+
461
+ {isSelected && (
462
+ <PanelBody title={__('Preview', '{{textDomain}}')}>
463
+ <Button
464
+ variant={isPreviewing ? 'primary' : 'secondary'}
465
+ onClick={() => setIsPreviewing(!isPreviewing)}
466
+ aria-pressed={isPreviewing}
467
+ isSmall
468
+ >
469
+ {isPreviewing
470
+ ? __('Disable Preview Mode', '{{textDomain}}')
471
+ : __('Enable Preview Mode', '{{textDomain}}')}
472
+ </Button>
473
+
474
+ {clickCount > 0 && (
475
+ <Notice status="info" isDismissible={false}>
476
+ {__('Current clicks:', '{{textDomain}}')} {clickCount}
477
+ {maxClicks > 0 && \` / \${maxClicks}\`}
478
+ </Notice>
479
+ )}
480
+ </PanelBody>
481
+ )}
482
+ </InspectorControls>
483
+
484
+ <div {...blockProps}>
485
+ <div
486
+ className={\`{{cssClassName}}__content \${isAnimating ? 'is-animating' : ''}\`}
487
+ style={previewContentStyle}
488
+ data-wp-on--click={isPreviewing ? 'actions.handleClick' : undefined}
489
+ data-wp-on--mouseenter={isPreviewing && interactiveMode === 'hover' ? 'actions.handleMouseEnter' : undefined}
490
+ data-wp-on--mouseleave={isPreviewing && interactiveMode === 'hover' ? 'actions.handleMouseLeave' : undefined}
491
+ >
492
+ <RichText
493
+ tagName="p"
494
+ value={attributes.content}
495
+ onChange={(value) => updateField('content', value)}
496
+ placeholder={__( {{titleJson}} + ' – click me to interact!', '{{textDomain}}')}
497
+ />
498
+
499
+ {!isValid && (
500
+ <Notice status="error" isDismissible={false}>
501
+ <p>
502
+ <strong>{__('Validation Errors', '{{textDomain}}')}</strong>
503
+ </p>
504
+ <ul style={validationListStyle}>
505
+ {errorMessages.map((error, index) => (
506
+ <li key={index}>{error}</li>
507
+ ))}
508
+ </ul>
509
+ </Notice>
510
+ )}
511
+
512
+ {showCounter && (
513
+ <div className="{{cssClassName}}__counter">
514
+ <span className="{{cssClassName}}__counter-label">
515
+ {__('Clicks:', '{{textDomain}}')}
516
+ </span>
517
+ <span
518
+ className="{{cssClassName}}__counter-value"
519
+ data-wp-text="state.clicks"
520
+ >
521
+ {clickCount}
522
+ </span>
523
+ </div>
524
+ )}
525
+
526
+ {maxClicks > 0 && (
527
+ <div className="{{cssClassName}}__progress">
528
+ <div
529
+ className="{{cssClassName}}__progress-bar"
530
+ style={progressBarStyle}
531
+ data-wp-style--width="state.progress + '%'"
532
+ />
533
+ </div>
534
+ )}
535
+
536
+ {animation !== 'none' && (
537
+ <div
538
+ className={\`{{cssClassName}}__animation \${isAnimating ? 'is-active' : ''}\`}
539
+ data-wp-class--is-active="state.isAnimating"
540
+ >
541
+ {animation}
542
+ </div>
543
+ )}
544
+ </div>
545
+ </div>
546
+ </>
547
+ );
548
+ }
549
+ `;
550
+ export const INTERACTIVITY_SAVE_TEMPLATE = `import { useBlockProps, RichText } from '@wordpress/block-editor';
551
+ import { __ } from '@wordpress/i18n';
552
+ import type { {{pascalCase}}Attributes } from './types';
553
+
554
+ export default function Save({ attributes }: { attributes: {{pascalCase}}Attributes }) {
555
+ const clickCount = attributes.clickCount ?? 0;
556
+ const interactiveMode = attributes.interactiveMode ?? 'click';
557
+ const animation = attributes.animation ?? 'none';
558
+ const isAnimating = attributes.isAnimating ?? false;
559
+ const isVisible = attributes.isVisible ?? true;
560
+ const maxClicks = attributes.maxClicks ?? 0;
561
+ const showCounter = attributes.showCounter ?? true;
562
+ const contentStyle = { textAlign: attributes.alignment };
563
+ const blockProps = useBlockProps.save({
564
+ className: \`{{cssClassName}} {{cssClassName}}--\${interactiveMode}\`,
565
+ 'data-wp-interactive': '{{slugKebabCase}}',
566
+ 'data-wp-context': JSON.stringify({
567
+ clicks: clickCount,
568
+ isAnimating,
569
+ isVisible,
570
+ animation,
571
+ maxClicks,
572
+ })
573
+ });
574
+
575
+ return (
576
+ <div {...blockProps}>
577
+ <div
578
+ className={\`{{cssClassName}}__content \${isAnimating ? 'is-animating' : ''}\`}
579
+ style={contentStyle}
580
+ data-wp-on--click="actions.handleClick"
581
+ data-wp-on--mouseenter={interactiveMode === 'hover' ? 'actions.handleMouseEnter' : undefined}
582
+ data-wp-on--mouseleave={interactiveMode === 'hover' ? 'actions.handleMouseLeave' : undefined}
583
+ data-wp-bind--hidden="!state.isVisible"
584
+ >
585
+ <RichText.Content
586
+ tagName="p"
587
+ value={attributes.content}
588
+ className="{{cssClassName}}__text"
589
+ />
590
+
591
+ {showCounter && (
592
+ <div
593
+ className="{{cssClassName}}__counter"
594
+ role="status"
595
+ aria-live="polite"
596
+ aria-atomic="true"
597
+ >
598
+ <span className="{{cssClassName}}__counter-label">
599
+ { __( 'Clicks:', '{{textDomain}}' ) }
600
+ </span>
601
+ <span
602
+ className="{{cssClassName}}__counter-value"
603
+ data-wp-text="state.clicks"
604
+ >
605
+ {clickCount}
606
+ </span>
607
+ </div>
608
+ )}
609
+
610
+ {maxClicks > 0 && (
611
+ <div className="{{cssClassName}}__progress">
612
+ <div
613
+ className="{{cssClassName}}__progress-bar"
614
+ role="progressbar"
615
+ aria-label={ __( 'Click progress', '{{textDomain}}' ) }
616
+ aria-valuemin={0}
617
+ aria-valuemax={maxClicks}
618
+ aria-valuenow={Math.min(clickCount, maxClicks)}
619
+ data-wp-bind--aria-valuenow="state.clampedClicks"
620
+ data-wp-style--width="state.progress + '%'"
621
+ />
622
+ </div>
623
+ )}
624
+
625
+ <div
626
+ className={\`{{cssClassName}}__animation \${animation}\`}
627
+ aria-hidden="true"
628
+ data-wp-class--is-active="state.isAnimating"
629
+ />
630
+
631
+ {maxClicks > 0 && (
632
+ <div
633
+ className="{{cssClassName}}__completion"
634
+ role="status"
635
+ aria-live="polite"
636
+ aria-atomic="true"
637
+ data-wp-bind--hidden="!state.isComplete"
638
+ >
639
+ { __( '🎉 Complete!', '{{textDomain}}' ) }
640
+ </div>
641
+ )}
642
+
643
+ <button
644
+ className="{{cssClassName}}__reset"
645
+ data-wp-on--click="actions.reset"
646
+ aria-label={ __( 'Reset counter', '{{textDomain}}' ) }
647
+ >
648
+ <span aria-hidden="true">↻</span>
649
+ <span className="screen-reader-text">
650
+ { __( 'Reset counter', '{{textDomain}}' ) }
651
+ </span>
652
+ </button>
653
+ </div>
654
+ </div>
655
+ );
656
+ }
657
+ `;
658
+ export const INTERACTIVITY_INDEX_TEMPLATE = `import {
659
+ registerScaffoldBlockType,
660
+ type BlockConfiguration,
661
+ } from '@wp-typia/block-types/blocks/registration';
662
+ import type { BlockSupports } from '@wp-typia/block-types/blocks/supports';
663
+ import {
664
+ buildScaffoldBlockRegistration,
665
+ parseScaffoldBlockMetadata,
666
+ } from '@wp-typia/block-runtime/blocks';
667
+
668
+ import Edit from './edit';
669
+ import Save from './save';
670
+ import metadata from './block-metadata';
671
+ import './editor.scss';
672
+ import './style.scss';
673
+
674
+ import type { {{pascalCase}}Attributes } from './types';
675
+
676
+ const scaffoldSupports = {
677
+ html: false,
678
+ align: true,
679
+ anchor: true,
680
+ className: true,
681
+ interactivity: true,
682
+ } satisfies BlockSupports;
683
+
684
+ const registration = buildScaffoldBlockRegistration(
685
+ parseScaffoldBlockMetadata<BlockConfiguration<{{pascalCase}}Attributes>>(metadata),
686
+ {
687
+ supports: scaffoldSupports,
688
+ edit: Edit,
689
+ save: Save,
690
+ }
691
+ );
692
+
693
+ registerScaffoldBlockType(registration.name, registration.settings);
694
+ `;
695
+ export const INTERACTIVITY_SCRIPT_TEMPLATE = `/**
696
+ * WordPress Interactivity API implementation for {{title}} block
697
+ */
698
+ import { store, getContext, getElement, withSyncEvent } from '@wordpress/interactivity';
699
+ import type { {{pascalCase}}Context } from './types';
700
+
701
+ function getBlockContext() {
702
+ return getContext<{{pascalCase}}Context>();
703
+ }
704
+
705
+ // Store configuration
706
+ store('{{slugKebabCase}}', {
707
+ // State - reactive data that updates the UI
708
+ state: {
709
+ get clicks() {
710
+ return getBlockContext().clicks;
711
+ },
712
+ get isAnimating() {
713
+ return getBlockContext().isAnimating;
714
+ },
715
+ get isVisible() {
716
+ return getBlockContext().isVisible;
717
+ },
718
+ get progress() {
719
+ const context = getBlockContext();
720
+ const clampedClicks =
721
+ context.maxClicks > 0
722
+ ? Math.min(context.clicks, context.maxClicks)
723
+ : context.clicks;
724
+ return context.maxClicks > 0 ? (clampedClicks / context.maxClicks) * 100 : 0;
725
+ },
726
+ get clampedClicks() {
727
+ const context = getBlockContext();
728
+ return context.maxClicks > 0
729
+ ? Math.min(context.clicks, context.maxClicks)
730
+ : context.clicks;
731
+ },
732
+ get isComplete() {
733
+ const context = getBlockContext();
734
+ return context.clicks >= context.maxClicks && context.maxClicks > 0;
735
+ }
736
+ },
737
+
738
+ // Actions - user interactions
739
+ actions: {
740
+ // Handle block click
741
+ handleClick: () => {
742
+ const context = getBlockContext();
743
+ const { ref } = getElement();
744
+
745
+ if (!ref) {
746
+ return;
747
+ }
748
+
749
+ if (context.maxClicks > 0 && context.clicks >= context.maxClicks) {
750
+ return;
751
+ }
752
+
753
+ const previousClicks = context.clicks;
754
+
755
+ // Increment click counter
756
+ context.clicks += 1;
757
+
758
+ // Trigger animation
759
+ if (context.animation !== 'none') {
760
+ context.isAnimating = true;
761
+ setTimeout(() => {
762
+ context.isAnimating = false;
763
+ }, 1000);
764
+ }
765
+
766
+ // Emit custom event
767
+ ref.dispatchEvent(new CustomEvent('{{slugKebabCase}}:click', {
768
+ detail: { clicks: context.clicks }
769
+ }));
770
+
771
+ // Check if max clicks reached
772
+ if (context.maxClicks > 0 && previousClicks < context.maxClicks && context.clicks === context.maxClicks) {
773
+ ref.dispatchEvent(new CustomEvent('{{slugKebabCase}}:complete', {
774
+ detail: { totalClicks: context.clicks }
775
+ }));
776
+ }
777
+ },
778
+
779
+ // Handle hover events
780
+ handleMouseEnter: () => {
781
+ const context = getBlockContext();
782
+ if (context.animation === 'none') return;
783
+ context.isAnimating = true;
784
+ },
785
+
786
+ handleMouseLeave: () => {
787
+ const context = getBlockContext();
788
+ if (context.animation === 'none') return;
789
+ context.isAnimating = false;
790
+ },
791
+
792
+ // Reset counter
793
+ reset: withSyncEvent((event: Event) => {
794
+ event.stopPropagation();
795
+ const context = getBlockContext();
796
+ context.clicks = 0;
797
+ context.isAnimating = false;
798
+ })
799
+ }
800
+ });
801
+ `;
802
+ export const INTERACTIVITY_VALIDATORS_TEMPLATE = `import typia from 'typia';
803
+ import currentManifest from "./manifest-defaults-document";
804
+ import { {{pascalCase}}Attributes, {{pascalCase}}ValidationResult } from "./types";
805
+ import { createTemplateValidatorToolkit } from "./validator-toolkit";
806
+
807
+ const scaffoldValidators = createTemplateValidatorToolkit<{{pascalCase}}Attributes>({
808
+ assert: typia.createAssert<{{pascalCase}}Attributes>(),
809
+ clone: typia.misc.createClone<{{pascalCase}}Attributes>() as (
810
+ value: {{pascalCase}}Attributes,
811
+ ) => {{pascalCase}}Attributes,
812
+ is: typia.createIs<{{pascalCase}}Attributes>(),
813
+ manifest: currentManifest,
814
+ prune: typia.misc.createPrune<{{pascalCase}}Attributes>(),
815
+ random: typia.createRandom<{{pascalCase}}Attributes>() as (
816
+ ...args: unknown[]
817
+ ) => {{pascalCase}}Attributes,
818
+ validate: typia.createValidate<{{pascalCase}}Attributes>(),
819
+ });
820
+
821
+ export const validate{{pascalCase}}Attributes =
822
+ scaffoldValidators.validateAttributes as (
823
+ attributes: unknown,
824
+ ) => {{pascalCase}}ValidationResult;
825
+
826
+ export const validators = scaffoldValidators.validators;
827
+
828
+ export const sanitize{{pascalCase}}Attributes =
829
+ scaffoldValidators.sanitizeAttributes as (
830
+ attributes: Partial<{{pascalCase}}Attributes>,
831
+ ) => {{pascalCase}}Attributes;
832
+
833
+ /**
834
+ * Runtime type guard for checking if an object is {{pascalCase}}Attributes.
835
+ */
836
+ export const is{{pascalCase}}Attributes = (obj: unknown): obj is {{pascalCase}}Attributes => {
837
+ return validators.is(obj);
838
+ };
839
+
840
+ export const createAttributeUpdater = scaffoldValidators.createAttributeUpdater;
841
+ `;
842
+ export const PERSISTENCE_EDIT_TEMPLATE = `import type { BlockEditProps } from '@wp-typia/block-types/blocks/registration';
843
+ import { __ } from '@wordpress/i18n';
844
+ import {
845
+ AlignmentToolbar,
846
+ BlockControls,
847
+ InspectorControls,
848
+ RichText,
849
+ useBlockProps,
850
+ } from '@wordpress/block-editor';
851
+ import {
852
+ Notice,
853
+ PanelBody,
854
+ TextControl,
855
+ } from '@wordpress/components';
856
+ import currentManifest from './manifest-document';
857
+ import {
858
+ InspectorFromManifest,
859
+ useEditorFields,
860
+ useTypedAttributeUpdater,
861
+ } from '@wp-typia/block-runtime/inspector';
862
+ import type { {{pascalCase}}Attributes } from './types';
863
+ import {
864
+ sanitize{{pascalCase}}Attributes,
865
+ validate{{pascalCase}}Attributes,
866
+ } from './validators';
867
+ import { useTypiaValidation } from './hooks';
868
+
869
+ type EditProps = BlockEditProps< {{pascalCase}}Attributes >;
870
+
871
+ export default function Edit( {
872
+ attributes,
873
+ setAttributes,
874
+ }: EditProps ) {
875
+ const editorFields = useEditorFields(
876
+ currentManifest,
877
+ {
878
+ manual: [ 'content', 'resourceKey' ],
879
+ labels: {
880
+ buttonLabel: __( 'Button Label', '{{textDomain}}' ),
881
+ resourceKey: __( 'Resource Key', '{{textDomain}}' ),
882
+ showCount: __( 'Show Count', '{{textDomain}}' ),
883
+ },
884
+ }
885
+ );
886
+ const { errorMessages, isValid } = useTypiaValidation(
887
+ attributes,
888
+ validate{{pascalCase}}Attributes
889
+ );
890
+ const validateEditorUpdate = (
891
+ nextAttributes: {{pascalCase}}Attributes
892
+ ) => {
893
+ try {
894
+ return {
895
+ data: sanitize{{pascalCase}}Attributes( nextAttributes ),
896
+ errors: [],
897
+ isValid: true as const,
898
+ };
899
+ } catch {
900
+ return validate{{pascalCase}}Attributes( nextAttributes );
901
+ }
902
+ };
903
+ const { updateField } = useTypedAttributeUpdater(
904
+ attributes,
905
+ setAttributes,
906
+ validateEditorUpdate
907
+ );
908
+ const alignmentValue = editorFields.getStringValue(
909
+ attributes,
910
+ 'alignment',
911
+ 'left'
912
+ );
913
+ const persistencePolicy = '{{persistencePolicy}}';
914
+ const persistencePolicyDescription = __(
915
+ {{persistencePolicyDescriptionJson}},
916
+ '{{textDomain}}'
917
+ );
918
+
919
+ return (
920
+ <>
921
+ <BlockControls>
922
+ <AlignmentToolbar
923
+ value={ alignmentValue }
924
+ onChange={ ( value ) =>
925
+ updateField(
926
+ 'alignment',
927
+ ( value || alignmentValue ) as NonNullable< {{pascalCase}}Attributes[ 'alignment' ] >
928
+ )
929
+ }
930
+ />
931
+ </BlockControls>
932
+ <InspectorControls>
933
+ <InspectorFromManifest
934
+ attributes={ attributes }
935
+ fieldLookup={ editorFields }
936
+ onChange={ updateField }
937
+ paths={ [ 'alignment', 'isVisible', 'showCount', 'buttonLabel' ] }
938
+ title={ __( 'Persistence Settings', '{{textDomain}}' ) }
939
+ >
940
+ <TextControl
941
+ label={ __( 'Resource Key', '{{textDomain}}' ) }
942
+ value={ attributes.resourceKey ?? '' }
943
+ onChange={ ( value ) => updateField( 'resourceKey', value ) }
944
+ help={ __( 'Stable persisted identifier used by the storage-backed counter endpoint.', '{{textDomain}}' ) }
945
+ />
946
+ <Notice status="info" isDismissible={ false }>
947
+ { __( 'Storage mode: {{dataStorageMode}}', '{{textDomain}}' ) }
948
+ </Notice>
949
+ <Notice status="info" isDismissible={ false }>
950
+ { __( 'Persistence policy: {{persistencePolicy}}', '{{textDomain}}' ) }
951
+ <br />
952
+ { persistencePolicyDescription }
953
+ </Notice>
954
+ <Notice status="info" isDismissible={ false }>
955
+ { __( 'Render mode: dynamic. \`render.php\` bootstraps durable post context, while fresh session-only write data is loaded from the dedicated \`/bootstrap\` endpoint after hydration.', '{{textDomain}}' ) }
956
+ </Notice>
957
+ </InspectorFromManifest>
958
+ { ! isValid && (
959
+ <PanelBody
960
+ title={ __( 'Validation Errors', '{{textDomain}}' ) }
961
+ initialOpen
962
+ >
963
+ { errorMessages.map( ( error, index ) => (
964
+ <Notice key={ index } status="error" isDismissible={ false }>
965
+ { error }
966
+ </Notice>
967
+ ) ) }
968
+ </PanelBody>
969
+ ) }
970
+ </InspectorControls>
971
+ <div
972
+ { ...useBlockProps( {
973
+ className: '{{cssClassName}}',
974
+ style: {
975
+ textAlign:
976
+ alignmentValue as NonNullable< {{pascalCase}}Attributes[ 'alignment' ] >,
977
+ },
978
+ } ) }
979
+ >
980
+ <RichText
981
+ tagName="p"
982
+ value={ attributes.content }
983
+ onChange={ ( value ) => updateField( 'content', value ) }
984
+ placeholder={ __( {{titleJson}} + ' persistence block', '{{textDomain}}' ) }
985
+ />
986
+ <p className="{{cssClassName}}__meta">
987
+ { __( 'Resource key:', '{{textDomain}}' ) } { attributes.resourceKey || '—' }
988
+ </p>
989
+ <p className="{{cssClassName}}__meta">
990
+ { __( 'Storage mode:', '{{textDomain}}' ) } {{dataStorageMode}}
991
+ </p>
992
+ <p className="{{cssClassName}}__meta">
993
+ { __( 'Persistence policy:', '{{textDomain}}' ) } {{persistencePolicy}}
994
+ </p>
995
+ { ! isValid && (
996
+ <Notice status="error" isDismissible={ false }>
997
+ <ul>
998
+ { errorMessages.map( ( error, index ) => <li key={ index }>{ error }</li> ) }
999
+ </ul>
1000
+ </Notice>
1001
+ ) }
1002
+ </div>
1003
+ </>
1004
+ );
1005
+ }
1006
+ `;
1007
+ export const PERSISTENCE_INDEX_TEMPLATE = `import {
1008
+ \tregisterScaffoldBlockType,
1009
+ \ttype BlockConfiguration,
1010
+ } from '@wp-typia/block-types/blocks/registration';
1011
+ import {
1012
+ buildScaffoldBlockRegistration,
1013
+ parseScaffoldBlockMetadata,
1014
+ } from '@wp-typia/block-runtime/blocks';
1015
+
1016
+ import Edit from './edit';
1017
+ import Save from './save';
1018
+ import metadata from './block-metadata';
1019
+ import './style.scss';
1020
+
1021
+ import type { {{pascalCase}}Attributes } from './types';
1022
+
1023
+ const registration = buildScaffoldBlockRegistration(
1024
+ parseScaffoldBlockMetadata<BlockConfiguration< {{pascalCase}}Attributes >>( metadata ),
1025
+ {
1026
+ edit: Edit,
1027
+ save: Save,
1028
+ }
1029
+ );
1030
+
1031
+ registerScaffoldBlockType(registration.name, registration.settings);
1032
+ `;
1033
+ export const PERSISTENCE_SAVE_TEMPLATE = `export default function Save() {
1034
+ // This block is intentionally server-rendered. PHP bootstraps post context,
1035
+ // storage-backed state, and write-policy data before the frontend hydrates.
1036
+ return null;
1037
+ }
1038
+ `;
1039
+ export const PERSISTENCE_VALIDATORS_TEMPLATE = `import typia from 'typia';
1040
+ import currentManifest from './manifest-defaults-document';
1041
+ import type {
1042
+ {{pascalCase}}Attributes,
1043
+ {{pascalCase}}ValidationResult,
1044
+ } from './types';
1045
+ import { generateResourceKey } from '@wp-typia/block-runtime/identifiers';
1046
+ import { createTemplateValidatorToolkit } from './validator-toolkit';
1047
+
1048
+ const scaffoldValidators = createTemplateValidatorToolkit< {{pascalCase}}Attributes >( {
1049
+ assert: typia.createAssert< {{pascalCase}}Attributes >(),
1050
+ clone: typia.misc.createClone< {{pascalCase}}Attributes >() as (
1051
+ value: {{pascalCase}}Attributes,
1052
+ ) => {{pascalCase}}Attributes,
1053
+ is: typia.createIs< {{pascalCase}}Attributes >(),
1054
+ manifest: currentManifest,
1055
+ prune: typia.misc.createPrune< {{pascalCase}}Attributes >(),
1056
+ random: typia.createRandom< {{pascalCase}}Attributes >() as (
1057
+ ...args: unknown[]
1058
+ ) => {{pascalCase}}Attributes,
1059
+ finalize: ( normalized ) => ( {
1060
+ ...normalized,
1061
+ resourceKey:
1062
+ normalized.resourceKey && normalized.resourceKey.length > 0
1063
+ ? normalized.resourceKey
1064
+ : generateResourceKey( '{{slugKebabCase}}' ),
1065
+ } ),
1066
+ validate: typia.createValidate< {{pascalCase}}Attributes >(),
1067
+ } );
1068
+
1069
+ export const validators = scaffoldValidators.validators;
1070
+
1071
+ export const validate{{pascalCase}}Attributes =
1072
+ scaffoldValidators.validateAttributes as (
1073
+ attributes: unknown
1074
+ ) => {{pascalCase}}ValidationResult;
1075
+
1076
+ export const sanitize{{pascalCase}}Attributes =
1077
+ scaffoldValidators.sanitizeAttributes as (
1078
+ attributes: Partial< {{pascalCase}}Attributes >
1079
+ ) => {{pascalCase}}Attributes;
1080
+
1081
+ export const createAttributeUpdater = scaffoldValidators.createAttributeUpdater;
1082
+ `;
1083
+ export const PERSISTENCE_INTERACTIVITY_TEMPLATE = `import { getContext, store } from '@wordpress/interactivity';
1084
+ import { generatePublicWriteRequestId } from '@wp-typia/block-runtime/identifiers';
1085
+
1086
+ import { fetchBootstrap, fetchState, writeState } from './api';
1087
+ import type {
1088
+ {{pascalCase}}ClientState,
1089
+ {{pascalCase}}Context,
1090
+ {{pascalCase}}State,
1091
+ } from './types';
1092
+ import type {
1093
+ {{pascalCase}}WriteStateRequest,
1094
+ } from './api-types';
1095
+
1096
+ function hasExpiredPublicWriteToken(
1097
+ expiresAt?: number
1098
+ ): boolean {
1099
+ return (
1100
+ typeof expiresAt === 'number' &&
1101
+ expiresAt > 0 &&
1102
+ Date.now() >= expiresAt * 1000
1103
+ );
1104
+ }
1105
+
1106
+ function getWriteBlockedMessage(
1107
+ context: {{pascalCase}}Context
1108
+ ): string {
1109
+ return context.persistencePolicy === 'authenticated'
1110
+ ? 'Sign in to persist this counter.'
1111
+ : 'Public writes are temporarily unavailable.';
1112
+ }
1113
+
1114
+ const BOOTSTRAP_MAX_ATTEMPTS = 3;
1115
+ const BOOTSTRAP_RETRY_DELAYS_MS = [ 250, 500 ];
1116
+
1117
+ async function waitForBootstrapRetry( delayMs: number ): Promise< void > {
1118
+ await new Promise( ( resolve ) => {
1119
+ setTimeout( resolve, delayMs );
1120
+ } );
1121
+ }
1122
+
1123
+ function getClientState(
1124
+ context: {{pascalCase}}Context
1125
+ ): {{pascalCase}}ClientState {
1126
+ if ( context.client ) {
1127
+ return context.client;
1128
+ }
1129
+
1130
+ context.client = {
1131
+ bootstrapError: '',
1132
+ writeExpiry: 0,
1133
+ writeNonce: '',
1134
+ writeToken: '',
1135
+ };
1136
+
1137
+ return context.client;
1138
+ }
1139
+
1140
+ function clearBootstrapError(
1141
+ context: {{pascalCase}}Context,
1142
+ clientState: {{pascalCase}}ClientState
1143
+ ): void {
1144
+ if ( context.error === clientState.bootstrapError ) {
1145
+ context.error = '';
1146
+ }
1147
+ clientState.bootstrapError = '';
1148
+ }
1149
+
1150
+ function setBootstrapError(
1151
+ context: {{pascalCase}}Context,
1152
+ clientState: {{pascalCase}}ClientState,
1153
+ message: string
1154
+ ): void {
1155
+ clientState.bootstrapError = message;
1156
+ context.error = message;
1157
+ }
1158
+
1159
+ const { actions, state } = store( '{{slugKebabCase}}', {
1160
+ state: {
1161
+ isHydrated: false,
1162
+ } as {{pascalCase}}State,
1163
+
1164
+ actions: {
1165
+ async loadState() {
1166
+ const context = getContext< {{pascalCase}}Context >();
1167
+ if ( context.postId <= 0 || ! context.resourceKey ) {
1168
+ return;
1169
+ }
1170
+
1171
+ context.isLoading = true;
1172
+ context.error = '';
1173
+
1174
+ try {
1175
+ const result = await fetchState( {
1176
+ postId: context.postId,
1177
+ resourceKey: context.resourceKey,
1178
+ }, {
1179
+ transportTarget: 'frontend',
1180
+ } );
1181
+ if ( ! result.isValid || ! result.data ) {
1182
+ context.error = result.errors[ 0 ]?.expected ?? 'Unable to load counter';
1183
+ return;
1184
+ }
1185
+ context.count = result.data.count;
1186
+ } catch ( error ) {
1187
+ context.error =
1188
+ error instanceof Error ? error.message : 'Unknown loading error';
1189
+ } finally {
1190
+ context.isLoading = false;
1191
+ }
1192
+ },
1193
+ async loadBootstrap() {
1194
+ const context = getContext< {{pascalCase}}Context >();
1195
+ const clientState = getClientState( context );
1196
+ if ( context.postId <= 0 || ! context.resourceKey ) {
1197
+ context.bootstrapReady = true;
1198
+ context.canWrite = false;
1199
+ clientState.bootstrapError = '';
1200
+ clientState.writeExpiry = 0;
1201
+ clientState.writeNonce = '';
1202
+ clientState.writeToken = '';
1203
+ return;
1204
+ }
1205
+
1206
+ context.isBootstrapping = true;
1207
+
1208
+ let bootstrapSucceeded = false;
1209
+ let lastBootstrapError =
1210
+ 'Unable to initialize write access';
1211
+ const includePublicWriteCredentials = {{isPublicPersistencePolicy}};
1212
+ const includeRestNonce = {{isAuthenticatedPersistencePolicy}};
1213
+
1214
+ for ( let attempt = 1; attempt <= BOOTSTRAP_MAX_ATTEMPTS; attempt += 1 ) {
1215
+ try {
1216
+ const result = await fetchBootstrap( {
1217
+ postId: context.postId,
1218
+ resourceKey: context.resourceKey,
1219
+ }, {
1220
+ transportTarget: 'frontend',
1221
+ } );
1222
+ if ( ! result.isValid || ! result.data ) {
1223
+ lastBootstrapError =
1224
+ result.errors[ 0 ]?.expected ??
1225
+ 'Unable to initialize write access';
1226
+ if ( attempt < BOOTSTRAP_MAX_ATTEMPTS ) {
1227
+ await waitForBootstrapRetry(
1228
+ BOOTSTRAP_RETRY_DELAYS_MS[ attempt - 1 ] ?? 750
1229
+ );
1230
+ continue;
1231
+ }
1232
+ break;
1233
+ }
1234
+
1235
+ clientState.writeExpiry =
1236
+ includePublicWriteCredentials &&
1237
+ 'publicWriteExpiresAt' in result.data &&
1238
+ typeof result.data.publicWriteExpiresAt === 'number' &&
1239
+ result.data.publicWriteExpiresAt > 0
1240
+ ? result.data.publicWriteExpiresAt
1241
+ : 0;
1242
+ clientState.writeToken =
1243
+ includePublicWriteCredentials &&
1244
+ 'publicWriteToken' in result.data &&
1245
+ typeof result.data.publicWriteToken === 'string' &&
1246
+ result.data.publicWriteToken.length > 0
1247
+ ? result.data.publicWriteToken
1248
+ : '';
1249
+ clientState.writeNonce =
1250
+ includeRestNonce &&
1251
+ 'restNonce' in result.data &&
1252
+ typeof result.data.restNonce === 'string' &&
1253
+ result.data.restNonce.length > 0
1254
+ ? result.data.restNonce
1255
+ : '';
1256
+ context.bootstrapReady = true;
1257
+ context.canWrite =
1258
+ result.data.canWrite === true &&
1259
+ (
1260
+ context.persistencePolicy === 'authenticated'
1261
+ ? clientState.writeNonce.length > 0
1262
+ : clientState.writeToken.length > 0 &&
1263
+ ! hasExpiredPublicWriteToken( clientState.writeExpiry )
1264
+ );
1265
+ clearBootstrapError( context, clientState );
1266
+ bootstrapSucceeded = true;
1267
+ break;
1268
+ } catch ( error ) {
1269
+ lastBootstrapError =
1270
+ error instanceof Error ? error.message : 'Unknown bootstrap error';
1271
+ if ( attempt < BOOTSTRAP_MAX_ATTEMPTS ) {
1272
+ await waitForBootstrapRetry(
1273
+ BOOTSTRAP_RETRY_DELAYS_MS[ attempt - 1 ] ?? 750
1274
+ );
1275
+ continue;
1276
+ }
1277
+ break;
1278
+ }
1279
+ }
1280
+
1281
+ if ( ! bootstrapSucceeded ) {
1282
+ context.bootstrapReady = false;
1283
+ context.canWrite = false;
1284
+ clientState.writeExpiry = 0;
1285
+ clientState.writeNonce = '';
1286
+ clientState.writeToken = '';
1287
+ setBootstrapError( context, clientState, lastBootstrapError );
1288
+ }
1289
+ context.isBootstrapping = false;
1290
+ },
1291
+ async increment() {
1292
+ const context = getContext< {{pascalCase}}Context >();
1293
+ const clientState = getClientState( context );
1294
+ if ( context.postId <= 0 || ! context.resourceKey ) {
1295
+ return;
1296
+ }
1297
+ if ( ! context.bootstrapReady ) {
1298
+ await actions.loadBootstrap();
1299
+ }
1300
+ if ( ! context.bootstrapReady ) {
1301
+ context.error = 'Write access is still initializing.';
1302
+ return;
1303
+ }
1304
+ if (
1305
+ context.persistencePolicy === 'public' &&
1306
+ hasExpiredPublicWriteToken( clientState.writeExpiry )
1307
+ ) {
1308
+ await actions.loadBootstrap();
1309
+ }
1310
+ if (
1311
+ context.persistencePolicy === 'public' &&
1312
+ hasExpiredPublicWriteToken( clientState.writeExpiry )
1313
+ ) {
1314
+ context.canWrite = false;
1315
+ context.error = getWriteBlockedMessage( context );
1316
+ return;
1317
+ }
1318
+ if ( ! context.canWrite ) {
1319
+ context.error = getWriteBlockedMessage( context );
1320
+ return;
1321
+ }
1322
+
1323
+ context.isSaving = true;
1324
+ context.error = '';
1325
+
1326
+ try {
1327
+ const request = {
1328
+ delta: 1,
1329
+ postId: context.postId,
1330
+ resourceKey: context.resourceKey,
1331
+ } as {{pascalCase}}WriteStateRequest;
1332
+ if ( {{isPublicPersistencePolicy}} ) {
1333
+ request.publicWriteRequestId =
1334
+ generatePublicWriteRequestId() as {{pascalCase}}WriteStateRequest[ 'publicWriteRequestId' ];
1335
+ if ( clientState.writeToken.length > 0 ) {
1336
+ request.publicWriteToken =
1337
+ clientState.writeToken as {{pascalCase}}WriteStateRequest[ 'publicWriteToken' ];
1338
+ }
1339
+ }
1340
+ const result = await writeState( request, {
1341
+ restNonce:
1342
+ clientState.writeNonce.length > 0
1343
+ ? clientState.writeNonce
1344
+ : undefined,
1345
+ transportTarget: 'frontend',
1346
+ } );
1347
+ if ( ! result.isValid || ! result.data ) {
1348
+ context.error = result.errors[ 0 ]?.expected ?? 'Unable to update counter';
1349
+ return;
1350
+ }
1351
+ context.count = result.data.count;
1352
+ context.storage = result.data.storage;
1353
+ } catch ( error ) {
1354
+ context.error =
1355
+ error instanceof Error ? error.message : 'Unknown update error';
1356
+ } finally {
1357
+ context.isSaving = false;
1358
+ }
1359
+ },
1360
+ },
1361
+
1362
+ callbacks: {
1363
+ init() {
1364
+ const context = getContext< {{pascalCase}}Context >();
1365
+ context.client = {
1366
+ bootstrapError: '',
1367
+ writeExpiry: 0,
1368
+ writeNonce: '',
1369
+ writeToken: '',
1370
+ };
1371
+ context.bootstrapReady = false;
1372
+ context.canWrite = false;
1373
+ context.count = 0;
1374
+ context.error = '';
1375
+ context.isBootstrapping = false;
1376
+ context.isLoading = false;
1377
+ context.isSaving = false;
1378
+ },
1379
+ mounted() {
1380
+ state.isHydrated = true;
1381
+ if ( typeof document !== 'undefined' ) {
1382
+ document.documentElement.dataset[ '{{slugCamelCase}}Hydrated' ] = 'true';
1383
+ }
1384
+ void Promise.allSettled( [
1385
+ actions.loadState(),
1386
+ actions.loadBootstrap(),
1387
+ ] );
1388
+ },
1389
+ },
1390
+ } );
1391
+ `;
1392
+ export const COMPOUND_PARENT_EDIT_TEMPLATE = `import type { BlockEditProps } from '@wp-typia/block-types/blocks/registration';
1393
+ import { __ } from '@wordpress/i18n';
1394
+ import {
1395
+ InspectorControls,
1396
+ InnerBlocks,
1397
+ RichText,
1398
+ useBlockProps,
1399
+ } from '@wordpress/block-editor';
1400
+ import { Notice, PanelBody, ToggleControl } from '@wordpress/components';
1401
+
1402
+ import {
1403
+ ALLOWED_CHILD_BLOCKS,
1404
+ DEFAULT_CHILD_TEMPLATE,
1405
+ } from './children';
1406
+ import { useTypiaValidation } from './hooks';
1407
+ import type { {{pascalCase}}Attributes } from './types';
1408
+ import {
1409
+ createAttributeUpdater,
1410
+ validate{{pascalCase}}Attributes,
1411
+ } from './validators';
1412
+
1413
+ type EditProps = BlockEditProps< {{pascalCase}}Attributes >;
1414
+
1415
+ export default function Edit( {
1416
+ attributes,
1417
+ setAttributes,
1418
+ }: EditProps ) {
1419
+ const { errorMessages, isValid } = useTypiaValidation(
1420
+ attributes,
1421
+ validate{{pascalCase}}Attributes
1422
+ );
1423
+ const updateAttribute = createAttributeUpdater( attributes, setAttributes );
1424
+ const blockProps = useBlockProps( {
1425
+ className: '{{cssClassName}}',
1426
+ } );
1427
+
1428
+ return (
1429
+ <>
1430
+ <InspectorControls>
1431
+ <PanelBody title={ __( 'Compound Settings', '{{textDomain}}' ) }>
1432
+ <ToggleControl
1433
+ label={ __( 'Show dividers between items', '{{textDomain}}' ) }
1434
+ checked={ attributes.showDividers ?? true }
1435
+ onChange={ ( value ) => updateAttribute( 'showDividers', value ) }
1436
+ />
1437
+ </PanelBody>
1438
+ { ! isValid && (
1439
+ <PanelBody title={ __( 'Validation Errors', '{{textDomain}}' ) } initialOpen>
1440
+ { errorMessages.map( ( error, index ) => (
1441
+ <Notice key={ index } status="error" isDismissible={ false }>
1442
+ { error }
1443
+ </Notice>
1444
+ ) ) }
1445
+ </PanelBody>
1446
+ ) }
1447
+ </InspectorControls>
1448
+ <div { ...blockProps }>
1449
+ <RichText
1450
+ tagName="h3"
1451
+ className="{{cssClassName}}__heading"
1452
+ value={ attributes.heading }
1453
+ onChange={ ( heading ) => updateAttribute( 'heading', heading ) }
1454
+ placeholder={ __( {{titleJson}}, '{{textDomain}}' ) }
1455
+ />
1456
+ <RichText
1457
+ tagName="p"
1458
+ className="{{cssClassName}}__intro"
1459
+ value={ attributes.intro ?? '' }
1460
+ onChange={ ( intro ) => updateAttribute( 'intro', intro ) }
1461
+ placeholder={ __(
1462
+ 'Add and reorder internal items inside this compound block.',
1463
+ '{{textDomain}}'
1464
+ ) }
1465
+ />
1466
+ { ! isValid && (
1467
+ <Notice status="error" isDismissible={ false }>
1468
+ <ul>
1469
+ { errorMessages.map( ( error, index ) => <li key={ index }>{ error }</li> ) }
1470
+ </ul>
1471
+ </Notice>
1472
+ ) }
1473
+ <div className="{{cssClassName}}__items">
1474
+ <InnerBlocks
1475
+ allowedBlocks={ ALLOWED_CHILD_BLOCKS }
1476
+ renderAppender={ InnerBlocks.ButtonBlockAppender }
1477
+ template={ DEFAULT_CHILD_TEMPLATE }
1478
+ templateLock={ false }
1479
+ />
1480
+ </div>
1481
+ </div>
1482
+ </>
1483
+ );
1484
+ }
1485
+ `;
1486
+ export const COMPOUND_PARENT_SAVE_TEMPLATE = `import { InnerBlocks, RichText, useBlockProps } from '@wordpress/block-editor';
1487
+
1488
+ import type { {{pascalCase}}Attributes } from './types';
1489
+
1490
+ export default function Save( {
1491
+ attributes,
1492
+ }: {
1493
+ attributes: {{pascalCase}}Attributes;
1494
+ } ) {
1495
+ return (
1496
+ <div
1497
+ { ...useBlockProps.save( {
1498
+ className: '{{cssClassName}}',
1499
+ 'data-show-dividers': ( attributes.showDividers ?? true ) ? 'true' : 'false',
1500
+ } ) }
1501
+ >
1502
+ <RichText.Content
1503
+ tagName="h3"
1504
+ className="{{cssClassName}}__heading"
1505
+ value={ attributes.heading }
1506
+ />
1507
+ <RichText.Content
1508
+ tagName="p"
1509
+ className="{{cssClassName}}__intro"
1510
+ value={ attributes.intro ?? '' }
1511
+ />
1512
+ <div className="{{cssClassName}}__items">
1513
+ <InnerBlocks.Content />
1514
+ </div>
1515
+ </div>
1516
+ );
1517
+ }
1518
+ `;
1519
+ export const COMPOUND_PARENT_INDEX_TEMPLATE = `import {
1520
+ \tregisterScaffoldBlockType,
1521
+ \ttype BlockConfiguration,
1522
+ } from '@wp-typia/block-types/blocks/registration';
1523
+ import {
1524
+ buildScaffoldBlockRegistration,
1525
+ parseScaffoldBlockMetadata,
1526
+ } from '@wp-typia/block-runtime/blocks';
1527
+
1528
+ import Edit from './edit';
1529
+ import Save from './save';
1530
+ import metadata from './block-metadata';
1531
+ import './style.scss';
1532
+
1533
+ import type { {{pascalCase}}Attributes } from './types';
1534
+
1535
+ const registration = buildScaffoldBlockRegistration(
1536
+ parseScaffoldBlockMetadata<BlockConfiguration< {{pascalCase}}Attributes >>( metadata ),
1537
+ {
1538
+ edit: Edit,
1539
+ save: Save,
1540
+ }
1541
+ );
1542
+
1543
+ registerScaffoldBlockType(registration.name, registration.settings);
1544
+ `;
1545
+ export const COMPOUND_LOCAL_HOOKS_TEMPLATE = `export {
1546
+ formatValidationError,
1547
+ formatValidationErrors,
1548
+ useTypiaValidation,
1549
+ } from '../../hooks';
1550
+
1551
+ export type {
1552
+ TypiaValidationError,
1553
+ ValidationResult,
1554
+ ValidationState,
1555
+ } from '../../hooks';
1556
+ `;
1557
+ export const COMPOUND_PARENT_VALIDATORS_TEMPLATE = `import typia from 'typia';
1558
+ import currentManifest from './manifest-defaults-document';
1559
+ import type {
1560
+ {{pascalCase}}Attributes,
1561
+ {{pascalCase}}ValidationResult,
1562
+ } from './types';
1563
+ import { createTemplateValidatorToolkit } from '../../validator-toolkit';
1564
+
1565
+ const scaffoldValidators = createTemplateValidatorToolkit< {{pascalCase}}Attributes >( {
1566
+ assert: typia.createAssert< {{pascalCase}}Attributes >(),
1567
+ clone: typia.misc.createClone< {{pascalCase}}Attributes >() as (
1568
+ value: {{pascalCase}}Attributes,
1569
+ ) => {{pascalCase}}Attributes,
1570
+ is: typia.createIs< {{pascalCase}}Attributes >(),
1571
+ manifest: currentManifest,
1572
+ prune: typia.misc.createPrune< {{pascalCase}}Attributes >(),
1573
+ random: typia.createRandom< {{pascalCase}}Attributes >() as (
1574
+ ...args: unknown[]
1575
+ ) => {{pascalCase}}Attributes,
1576
+ validate: typia.createValidate< {{pascalCase}}Attributes >(),
1577
+ } );
1578
+
1579
+ export const validate{{pascalCase}}Attributes =
1580
+ scaffoldValidators.validateAttributes as (
1581
+ attributes: unknown
1582
+ ) => {{pascalCase}}ValidationResult;
1583
+
1584
+ export const validators = scaffoldValidators.validators;
1585
+
1586
+ export const sanitize{{pascalCase}}Attributes =
1587
+ scaffoldValidators.sanitizeAttributes as (
1588
+ attributes: Partial< {{pascalCase}}Attributes >
1589
+ ) => {{pascalCase}}Attributes;
1590
+
1591
+ export const createAttributeUpdater = scaffoldValidators.createAttributeUpdater;
1592
+ `;
1593
+ export const COMPOUND_CHILDREN_TEMPLATE = `import type { BlockTemplate } from '@wp-typia/block-types/blocks/registration';
1594
+
1595
+ export const DEFAULT_CHILD_BLOCK_NAME = '{{namespace}}/{{slugKebabCase}}-item';
1596
+
1597
+ export const ALLOWED_CHILD_BLOCKS = [
1598
+ DEFAULT_CHILD_BLOCK_NAME,
1599
+ // add-child: insert new allowed child block names here
1600
+ ];
1601
+
1602
+ export const DEFAULT_CHILD_TEMPLATE: BlockTemplate = [
1603
+ [
1604
+ DEFAULT_CHILD_BLOCK_NAME,
1605
+ {
1606
+ body: 'Add supporting details for the first internal item.',
1607
+ title: 'First Item',
1608
+ },
1609
+ ],
1610
+ [
1611
+ DEFAULT_CHILD_BLOCK_NAME,
1612
+ {
1613
+ body: 'Add supporting details for the second internal item.',
1614
+ title: 'Second Item',
1615
+ },
1616
+ ],
1617
+ ];
1618
+ `;
1619
+ export const COMPOUND_CHILD_EDIT_TEMPLATE = `import type { BlockEditProps } from '@wp-typia/block-types/blocks/registration';
1620
+ import { RichText, useBlockProps } from '@wordpress/block-editor';
1621
+ import { Notice } from '@wordpress/components';
1622
+ import { __ } from '@wordpress/i18n';
1623
+
1624
+ import { useTypiaValidation } from './hooks';
1625
+ import type { {{pascalCase}}ItemAttributes } from './types';
1626
+ import {
1627
+ createAttributeUpdater,
1628
+ validate{{pascalCase}}ItemAttributes,
1629
+ } from './validators';
1630
+
1631
+ type EditProps = BlockEditProps< {{pascalCase}}ItemAttributes >;
1632
+
1633
+ export default function Edit( {
1634
+ attributes,
1635
+ setAttributes,
1636
+ }: EditProps ) {
1637
+ const updateAttribute = createAttributeUpdater( attributes, setAttributes );
1638
+ const { errorMessages, isValid } = useTypiaValidation(
1639
+ attributes,
1640
+ validate{{pascalCase}}ItemAttributes
1641
+ );
1642
+
1643
+ return (
1644
+ <div { ...useBlockProps( { className: '{{compoundChildCssClassName}}' } ) }>
1645
+ <RichText
1646
+ tagName="h4"
1647
+ className="{{compoundChildCssClassName}}__title"
1648
+ value={ attributes.title ?? '' }
1649
+ onChange={ ( title ) => updateAttribute( 'title', title ) }
1650
+ placeholder={ __( {{compoundChildTitleJson}}, '{{textDomain}}' ) }
1651
+ />
1652
+ <RichText
1653
+ tagName="p"
1654
+ className="{{compoundChildCssClassName}}__body"
1655
+ value={ attributes.body ?? '' }
1656
+ onChange={ ( body ) => updateAttribute( 'body', body ) }
1657
+ placeholder={ __( 'Add supporting details for this internal item.', '{{textDomain}}' ) }
1658
+ />
1659
+ { ! isValid && (
1660
+ <Notice status="error" isDismissible={ false }>
1661
+ <ul>
1662
+ { errorMessages.map( ( error, index ) => <li key={ index }>{ error }</li> ) }
1663
+ </ul>
1664
+ </Notice>
1665
+ ) }
1666
+ </div>
1667
+ );
1668
+ }
1669
+ `;
1670
+ export const COMPOUND_CHILD_SAVE_TEMPLATE = `import { RichText, useBlockProps } from '@wordpress/block-editor';
1671
+
1672
+ import type { {{pascalCase}}ItemAttributes } from './types';
1673
+
1674
+ export default function Save( {
1675
+ attributes,
1676
+ }: {
1677
+ attributes: {{pascalCase}}ItemAttributes;
1678
+ } ) {
1679
+ return (
1680
+ <div { ...useBlockProps.save( { className: '{{compoundChildCssClassName}}' } ) }>
1681
+ <RichText.Content
1682
+ tagName="h4"
1683
+ className="{{compoundChildCssClassName}}__title"
1684
+ value={ attributes.title }
1685
+ />
1686
+ <RichText.Content
1687
+ tagName="p"
1688
+ className="{{compoundChildCssClassName}}__body"
1689
+ value={ attributes.body }
1690
+ />
1691
+ </div>
1692
+ );
1693
+ }
1694
+ `;
1695
+ export const COMPOUND_CHILD_INDEX_TEMPLATE = `import {
1696
+ \tregisterScaffoldBlockType,
1697
+ \ttype BlockConfiguration,
1698
+ } from '@wp-typia/block-types/blocks/registration';
1699
+ import {
1700
+ buildScaffoldBlockRegistration,
1701
+ parseScaffoldBlockMetadata,
1702
+ } from '@wp-typia/block-runtime/blocks';
1703
+
1704
+ import Edit from './edit';
1705
+ import Save from './save';
1706
+ import metadata from './block-metadata';
1707
+ import '../{{slugKebabCase}}/style.scss';
1708
+
1709
+ import type { {{pascalCase}}ItemAttributes } from './types';
1710
+
1711
+ const registration = buildScaffoldBlockRegistration(
1712
+ parseScaffoldBlockMetadata<BlockConfiguration< {{pascalCase}}ItemAttributes >>( metadata ),
1713
+ {
1714
+ edit: Edit,
1715
+ save: Save,
1716
+ }
1717
+ );
1718
+
1719
+ registerScaffoldBlockType(registration.name, registration.settings);
1720
+ `;
1721
+ export const COMPOUND_CHILD_VALIDATORS_TEMPLATE = `import typia from 'typia';
1722
+ import currentManifest from './manifest-defaults-document';
1723
+ import type {
1724
+ {{pascalCase}}ItemAttributes,
1725
+ {{pascalCase}}ItemValidationResult,
1726
+ } from './types';
1727
+ import { createTemplateValidatorToolkit } from '../../validator-toolkit';
1728
+
1729
+ const scaffoldValidators = createTemplateValidatorToolkit< {{pascalCase}}ItemAttributes >( {
1730
+ assert: typia.createAssert< {{pascalCase}}ItemAttributes >(),
1731
+ clone: typia.misc.createClone< {{pascalCase}}ItemAttributes >() as (
1732
+ value: {{pascalCase}}ItemAttributes,
1733
+ ) => {{pascalCase}}ItemAttributes,
1734
+ is: typia.createIs< {{pascalCase}}ItemAttributes >(),
1735
+ manifest: currentManifest,
1736
+ prune: typia.misc.createPrune< {{pascalCase}}ItemAttributes >(),
1737
+ random: typia.createRandom< {{pascalCase}}ItemAttributes >() as (
1738
+ ...args: unknown[]
1739
+ ) => {{pascalCase}}ItemAttributes,
1740
+ validate: typia.createValidate< {{pascalCase}}ItemAttributes >(),
1741
+ } );
1742
+
1743
+ export const validate{{pascalCase}}ItemAttributes =
1744
+ scaffoldValidators.validateAttributes as (
1745
+ attributes: unknown
1746
+ ) => {{pascalCase}}ItemValidationResult;
1747
+
1748
+ export const validators = scaffoldValidators.validators;
1749
+
1750
+ export const sanitize{{pascalCase}}ItemAttributes =
1751
+ scaffoldValidators.sanitizeAttributes as (
1752
+ attributes: Partial< {{pascalCase}}ItemAttributes >
1753
+ ) => {{pascalCase}}ItemAttributes;
1754
+
1755
+ export const createAttributeUpdater = scaffoldValidators.createAttributeUpdater;
1756
+ `;
1757
+ export const COMPOUND_PERSISTENCE_PARENT_EDIT_TEMPLATE = `import type { BlockEditProps } from '@wp-typia/block-types/blocks/registration';
1758
+ import { __ } from '@wordpress/i18n';
1759
+ import {
1760
+ InspectorControls,
1761
+ InnerBlocks,
1762
+ RichText,
1763
+ useBlockProps,
1764
+ } from '@wordpress/block-editor';
1765
+ import {
1766
+ Notice,
1767
+ PanelBody,
1768
+ TextControl,
1769
+ ToggleControl,
1770
+ } from '@wordpress/components';
1771
+
1772
+ import {
1773
+ ALLOWED_CHILD_BLOCKS,
1774
+ DEFAULT_CHILD_TEMPLATE,
1775
+ } from './children';
1776
+ import { useTypiaValidation } from './hooks';
1777
+ import type { {{pascalCase}}Attributes } from './types';
1778
+ import {
1779
+ createAttributeUpdater,
1780
+ validate{{pascalCase}}Attributes,
1781
+ } from './validators';
1782
+
1783
+ type EditProps = BlockEditProps< {{pascalCase}}Attributes >;
1784
+
1785
+ export default function Edit( {
1786
+ attributes,
1787
+ setAttributes,
1788
+ }: EditProps ) {
1789
+ const { errorMessages, isValid } = useTypiaValidation(
1790
+ attributes,
1791
+ validate{{pascalCase}}Attributes
1792
+ );
1793
+ const updateAttribute = createAttributeUpdater( attributes, setAttributes );
1794
+ const blockProps = useBlockProps( {
1795
+ className: '{{cssClassName}}',
1796
+ } );
1797
+
1798
+ return (
1799
+ <>
1800
+ <InspectorControls>
1801
+ <PanelBody title={ __( 'Compound Settings', '{{textDomain}}' ) }>
1802
+ <ToggleControl
1803
+ label={ __( 'Show dividers between items', '{{textDomain}}' ) }
1804
+ checked={ attributes.showDividers ?? true }
1805
+ onChange={ ( value ) => updateAttribute( 'showDividers', value ) }
1806
+ />
1807
+ <ToggleControl
1808
+ label={ __( 'Show persisted count', '{{textDomain}}' ) }
1809
+ checked={ attributes.showCount ?? true }
1810
+ onChange={ ( value ) => updateAttribute( 'showCount', value ) }
1811
+ />
1812
+ <TextControl
1813
+ label={ __( 'Button label', '{{textDomain}}' ) }
1814
+ value={ attributes.buttonLabel ?? 'Persist Count' }
1815
+ onChange={ ( buttonLabel ) => updateAttribute( 'buttonLabel', buttonLabel ) }
1816
+ />
1817
+ <TextControl
1818
+ label={ __( 'Resource key', '{{textDomain}}' ) }
1819
+ value={ attributes.resourceKey ?? '' }
1820
+ onChange={ ( resourceKey ) => updateAttribute( 'resourceKey', resourceKey ) }
1821
+ help={ __( 'Stable key used by the persisted counter endpoint.', '{{textDomain}}' ) }
1822
+ />
1823
+ <Notice status="info" isDismissible={ false }>
1824
+ { __( 'Storage mode: {{dataStorageMode}}', '{{textDomain}}' ) }
1825
+ </Notice>
1826
+ <Notice status="info" isDismissible={ false }>
1827
+ { __( 'Persistence policy: {{persistencePolicy}}', '{{textDomain}}' ) }
1828
+ </Notice>
1829
+ </PanelBody>
1830
+ { ! isValid && (
1831
+ <PanelBody title={ __( 'Validation Errors', '{{textDomain}}' ) } initialOpen>
1832
+ { errorMessages.map( ( error, index ) => (
1833
+ <Notice key={ index } status="error" isDismissible={ false }>
1834
+ { error }
1835
+ </Notice>
1836
+ ) ) }
1837
+ </PanelBody>
1838
+ ) }
1839
+ </InspectorControls>
1840
+ <div { ...blockProps }>
1841
+ <RichText
1842
+ tagName="h3"
1843
+ className="{{cssClassName}}__heading"
1844
+ value={ attributes.heading }
1845
+ onChange={ ( heading ) => updateAttribute( 'heading', heading ) }
1846
+ placeholder={ __( {{titleJson}}, '{{textDomain}}' ) }
1847
+ />
1848
+ <RichText
1849
+ tagName="p"
1850
+ className="{{cssClassName}}__intro"
1851
+ value={ attributes.intro ?? '' }
1852
+ onChange={ ( intro ) => updateAttribute( 'intro', intro ) }
1853
+ placeholder={ __(
1854
+ 'Add and reorder internal items inside this compound block.',
1855
+ '{{textDomain}}'
1856
+ ) }
1857
+ />
1858
+ { ! isValid && (
1859
+ <Notice status="error" isDismissible={ false }>
1860
+ <ul>
1861
+ { errorMessages.map( ( error, index ) => <li key={ index }>{ error }</li> ) }
1862
+ </ul>
1863
+ </Notice>
1864
+ ) }
1865
+ <p className="{{cssClassName}}__meta">
1866
+ { __( 'Resource key:', '{{textDomain}}' ) } { attributes.resourceKey || '—' }
1867
+ </p>
1868
+ <div className="{{cssClassName}}__items">
1869
+ <InnerBlocks
1870
+ allowedBlocks={ ALLOWED_CHILD_BLOCKS }
1871
+ renderAppender={ InnerBlocks.ButtonBlockAppender }
1872
+ template={ DEFAULT_CHILD_TEMPLATE }
1873
+ templateLock={ false }
1874
+ />
1875
+ </div>
1876
+ </div>
1877
+ </>
1878
+ );
1879
+ }
1880
+ `;
1881
+ export const COMPOUND_PERSISTENCE_PARENT_SAVE_TEMPLATE = `export default function Save() {
1882
+ return null;
1883
+ }
1884
+ `;
1885
+ export const COMPOUND_PERSISTENCE_PARENT_VALIDATORS_TEMPLATE = `import typia from 'typia';
1886
+ import currentManifest from './manifest-defaults-document';
1887
+ import type {
1888
+ {{pascalCase}}Attributes,
1889
+ {{pascalCase}}ValidationResult,
1890
+ } from './types';
1891
+ import { generateResourceKey } from '@wp-typia/block-runtime/identifiers';
1892
+ import { createTemplateValidatorToolkit } from '../../validator-toolkit';
1893
+
1894
+ const scaffoldValidators = createTemplateValidatorToolkit< {{pascalCase}}Attributes >( {
1895
+ assert: typia.createAssert< {{pascalCase}}Attributes >(),
1896
+ clone: typia.misc.createClone< {{pascalCase}}Attributes >() as (
1897
+ value: {{pascalCase}}Attributes,
1898
+ ) => {{pascalCase}}Attributes,
1899
+ is: typia.createIs< {{pascalCase}}Attributes >(),
1900
+ manifest: currentManifest,
1901
+ prune: typia.misc.createPrune< {{pascalCase}}Attributes >(),
1902
+ random: typia.createRandom< {{pascalCase}}Attributes >() as (
1903
+ ...args: unknown[]
1904
+ ) => {{pascalCase}}Attributes,
1905
+ finalize: ( normalized ) => ( {
1906
+ ...normalized,
1907
+ resourceKey:
1908
+ normalized.resourceKey && normalized.resourceKey.length > 0
1909
+ ? normalized.resourceKey
1910
+ : generateResourceKey( '{{slugKebabCase}}' ),
1911
+ } ),
1912
+ validate: typia.createValidate< {{pascalCase}}Attributes >(),
1913
+ } );
1914
+
1915
+ export const validators = scaffoldValidators.validators;
1916
+
1917
+ export const validate{{pascalCase}}Attributes =
1918
+ scaffoldValidators.validateAttributes as (
1919
+ attributes: unknown
1920
+ ) => {{pascalCase}}ValidationResult;
1921
+
1922
+ export const sanitize{{pascalCase}}Attributes =
1923
+ scaffoldValidators.sanitizeAttributes as (
1924
+ attributes: Partial< {{pascalCase}}Attributes >
1925
+ ) => {{pascalCase}}Attributes;
1926
+
1927
+ export const createAttributeUpdater = scaffoldValidators.createAttributeUpdater;
1928
+ `;
1929
+ export const COMPOUND_PERSISTENCE_PARENT_INTERACTIVITY_TEMPLATE = `import { getContext, store } from '@wordpress/interactivity';
1930
+ import { generatePublicWriteRequestId } from '@wp-typia/block-runtime/identifiers';
1931
+
1932
+ import { fetchBootstrap, fetchState, writeState } from './api';
1933
+ import type {
1934
+ {{pascalCase}}ClientState,
1935
+ {{pascalCase}}Context,
1936
+ {{pascalCase}}State,
1937
+ } from './types';
1938
+
1939
+ function hasExpiredPublicWriteToken(
1940
+ expiresAt?: number
1941
+ ): boolean {
1942
+ return (
1943
+ typeof expiresAt === 'number' &&
1944
+ expiresAt > 0 &&
1945
+ Date.now() >= expiresAt * 1000
1946
+ );
1947
+ }
1948
+
1949
+ function getWriteBlockedMessage(
1950
+ context: {{pascalCase}}Context
1951
+ ): string {
1952
+ return context.persistencePolicy === 'authenticated'
1953
+ ? 'Sign in to persist this counter.'
1954
+ : 'Public writes are temporarily unavailable.';
1955
+ }
1956
+
1957
+ const BOOTSTRAP_MAX_ATTEMPTS = 3;
1958
+ const BOOTSTRAP_RETRY_DELAYS_MS = [ 250, 500 ];
1959
+
1960
+ async function waitForBootstrapRetry( delayMs: number ): Promise< void > {
1961
+ await new Promise( ( resolve ) => {
1962
+ setTimeout( resolve, delayMs );
1963
+ } );
1964
+ }
1965
+
1966
+ function getClientState(
1967
+ context: {{pascalCase}}Context
1968
+ ): {{pascalCase}}ClientState {
1969
+ if ( context.client ) {
1970
+ return context.client;
1971
+ }
1972
+
1973
+ context.client = {
1974
+ bootstrapError: '',
1975
+ writeExpiry: 0,
1976
+ writeNonce: '',
1977
+ writeToken: '',
1978
+ };
1979
+
1980
+ return context.client;
1981
+ }
1982
+
1983
+ function clearBootstrapError(
1984
+ context: {{pascalCase}}Context,
1985
+ clientState: {{pascalCase}}ClientState
1986
+ ): void {
1987
+ if ( context.error === clientState.bootstrapError ) {
1988
+ context.error = '';
1989
+ }
1990
+ clientState.bootstrapError = '';
1991
+ }
1992
+
1993
+ function setBootstrapError(
1994
+ context: {{pascalCase}}Context,
1995
+ clientState: {{pascalCase}}ClientState,
1996
+ message: string
1997
+ ): void {
1998
+ clientState.bootstrapError = message;
1999
+ context.error = message;
2000
+ }
2001
+
2002
+ const { actions, state } = store( '{{slugKebabCase}}', {
2003
+ state: {
2004
+ isHydrated: false,
2005
+ } as {{pascalCase}}State,
2006
+
2007
+ actions: {
2008
+ async loadState() {
2009
+ const context = getContext< {{pascalCase}}Context >();
2010
+ if ( context.postId <= 0 || ! context.resourceKey ) {
2011
+ return;
2012
+ }
2013
+
2014
+ context.isLoading = true;
2015
+ context.error = '';
2016
+
2017
+ try {
2018
+ const result = await fetchState( {
2019
+ postId: context.postId,
2020
+ resourceKey: context.resourceKey,
2021
+ }, {
2022
+ transportTarget: 'frontend',
2023
+ } );
2024
+ if ( ! result.isValid || ! result.data ) {
2025
+ context.error = result.errors[ 0 ]?.expected ?? 'Unable to load counter';
2026
+ return;
2027
+ }
2028
+ context.count = result.data.count;
2029
+ } catch ( error ) {
2030
+ context.error =
2031
+ error instanceof Error ? error.message : 'Unknown loading error';
2032
+ } finally {
2033
+ context.isLoading = false;
2034
+ }
2035
+ },
2036
+ async loadBootstrap() {
2037
+ const context = getContext< {{pascalCase}}Context >();
2038
+ const clientState = getClientState( context );
2039
+ if ( context.postId <= 0 || ! context.resourceKey ) {
2040
+ context.bootstrapReady = true;
2041
+ context.canWrite = false;
2042
+ clientState.bootstrapError = '';
2043
+ clientState.writeExpiry = 0;
2044
+ clientState.writeNonce = '';
2045
+ clientState.writeToken = '';
2046
+ return;
2047
+ }
2048
+
2049
+ context.isBootstrapping = true;
2050
+
2051
+ let bootstrapSucceeded = false;
2052
+ let lastBootstrapError =
2053
+ 'Unable to initialize write access';
2054
+ const includePublicWriteCredentials = {{isPublicPersistencePolicy}};
2055
+ const includeRestNonce = {{isAuthenticatedPersistencePolicy}};
2056
+
2057
+ for ( let attempt = 1; attempt <= BOOTSTRAP_MAX_ATTEMPTS; attempt += 1 ) {
2058
+ try {
2059
+ const result = await fetchBootstrap( {
2060
+ postId: context.postId,
2061
+ resourceKey: context.resourceKey,
2062
+ }, {
2063
+ transportTarget: 'frontend',
2064
+ } );
2065
+ if ( ! result.isValid || ! result.data ) {
2066
+ lastBootstrapError =
2067
+ result.errors[ 0 ]?.expected ??
2068
+ 'Unable to initialize write access';
2069
+ if ( attempt < BOOTSTRAP_MAX_ATTEMPTS ) {
2070
+ await waitForBootstrapRetry(
2071
+ BOOTSTRAP_RETRY_DELAYS_MS[ attempt - 1 ] ?? 750
2072
+ );
2073
+ continue;
2074
+ }
2075
+ break;
2076
+ }
2077
+
2078
+ clientState.writeExpiry =
2079
+ includePublicWriteCredentials &&
2080
+ 'publicWriteExpiresAt' in result.data &&
2081
+ typeof result.data.publicWriteExpiresAt === 'number' &&
2082
+ result.data.publicWriteExpiresAt > 0
2083
+ ? result.data.publicWriteExpiresAt
2084
+ : 0;
2085
+ clientState.writeToken =
2086
+ includePublicWriteCredentials &&
2087
+ 'publicWriteToken' in result.data &&
2088
+ typeof result.data.publicWriteToken === 'string' &&
2089
+ result.data.publicWriteToken.length > 0
2090
+ ? result.data.publicWriteToken
2091
+ : '';
2092
+ clientState.writeNonce =
2093
+ includeRestNonce &&
2094
+ 'restNonce' in result.data &&
2095
+ typeof result.data.restNonce === 'string' &&
2096
+ result.data.restNonce.length > 0
2097
+ ? result.data.restNonce
2098
+ : '';
2099
+ context.bootstrapReady = true;
2100
+ context.canWrite =
2101
+ result.data.canWrite === true &&
2102
+ (
2103
+ context.persistencePolicy === 'authenticated'
2104
+ ? clientState.writeNonce.length > 0
2105
+ : clientState.writeToken.length > 0 &&
2106
+ ! hasExpiredPublicWriteToken( clientState.writeExpiry )
2107
+ );
2108
+ clearBootstrapError( context, clientState );
2109
+ bootstrapSucceeded = true;
2110
+ break;
2111
+ } catch ( error ) {
2112
+ lastBootstrapError =
2113
+ error instanceof Error ? error.message : 'Unknown bootstrap error';
2114
+ if ( attempt < BOOTSTRAP_MAX_ATTEMPTS ) {
2115
+ await waitForBootstrapRetry(
2116
+ BOOTSTRAP_RETRY_DELAYS_MS[ attempt - 1 ] ?? 750
2117
+ );
2118
+ continue;
2119
+ }
2120
+ break;
2121
+ }
2122
+ }
2123
+
2124
+ if ( ! bootstrapSucceeded ) {
2125
+ context.bootstrapReady = false;
2126
+ context.canWrite = false;
2127
+ clientState.writeExpiry = 0;
2128
+ clientState.writeNonce = '';
2129
+ clientState.writeToken = '';
2130
+ setBootstrapError( context, clientState, lastBootstrapError );
2131
+ }
2132
+ context.isBootstrapping = false;
2133
+ },
2134
+ async increment() {
2135
+ const context = getContext< {{pascalCase}}Context >();
2136
+ const clientState = getClientState( context );
2137
+ if ( context.postId <= 0 || ! context.resourceKey ) {
2138
+ return;
2139
+ }
2140
+ if ( ! context.bootstrapReady ) {
2141
+ await actions.loadBootstrap();
2142
+ }
2143
+ if ( ! context.bootstrapReady ) {
2144
+ context.error = 'Write access is still initializing.';
2145
+ return;
2146
+ }
2147
+ if (
2148
+ context.persistencePolicy === 'public' &&
2149
+ hasExpiredPublicWriteToken( clientState.writeExpiry )
2150
+ ) {
2151
+ await actions.loadBootstrap();
2152
+ }
2153
+ if (
2154
+ context.persistencePolicy === 'public' &&
2155
+ hasExpiredPublicWriteToken( clientState.writeExpiry )
2156
+ ) {
2157
+ context.canWrite = false;
2158
+ context.error = getWriteBlockedMessage( context );
2159
+ return;
2160
+ }
2161
+ if ( ! context.canWrite ) {
2162
+ context.error = getWriteBlockedMessage( context );
2163
+ return;
2164
+ }
2165
+
2166
+ context.isSaving = true;
2167
+ context.error = '';
2168
+
2169
+ try {
2170
+ const result = await writeState( {
2171
+ delta: 1,
2172
+ postId: context.postId,
2173
+ publicWriteRequestId:
2174
+ context.persistencePolicy === 'public'
2175
+ ? generatePublicWriteRequestId()
2176
+ : undefined,
2177
+ publicWriteToken:
2178
+ context.persistencePolicy === 'public' &&
2179
+ clientState.writeToken.length > 0
2180
+ ? clientState.writeToken
2181
+ : undefined,
2182
+ resourceKey: context.resourceKey,
2183
+ }, {
2184
+ restNonce:
2185
+ clientState.writeNonce.length > 0
2186
+ ? clientState.writeNonce
2187
+ : undefined,
2188
+ transportTarget: 'frontend',
2189
+ } );
2190
+ if ( ! result.isValid || ! result.data ) {
2191
+ context.error = result.errors[ 0 ]?.expected ?? 'Unable to update counter';
2192
+ return;
2193
+ }
2194
+ context.count = result.data.count;
2195
+ context.storage = result.data.storage;
2196
+ } catch ( error ) {
2197
+ context.error =
2198
+ error instanceof Error ? error.message : 'Unknown update error';
2199
+ } finally {
2200
+ context.isSaving = false;
2201
+ }
2202
+ },
2203
+ },
2204
+
2205
+ callbacks: {
2206
+ init() {
2207
+ const context = getContext< {{pascalCase}}Context >();
2208
+ context.client = {
2209
+ bootstrapError: '',
2210
+ writeExpiry: 0,
2211
+ writeNonce: '',
2212
+ writeToken: '',
2213
+ };
2214
+ context.bootstrapReady = false;
2215
+ context.canWrite = false;
2216
+ context.count = 0;
2217
+ context.error = '';
2218
+ context.isBootstrapping = false;
2219
+ context.isLoading = false;
2220
+ context.isSaving = false;
2221
+ },
2222
+ mounted() {
2223
+ state.isHydrated = true;
2224
+ if ( typeof document !== 'undefined' ) {
2225
+ document.documentElement.dataset[ '{{slugCamelCase}}Hydrated' ] = 'true';
2226
+ }
2227
+ void Promise.allSettled( [
2228
+ actions.loadState(),
2229
+ actions.loadBootstrap(),
2230
+ ] );
2231
+ },
2232
+ },
2233
+ } );
2234
+ `;