@wp-typia/project-tools 0.16.7 → 0.16.9

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