@wp-typia/project-tools 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { getWorkspaceBlockSelectOptions } from "./workspace-inventory.js";
1
2
  /**
2
3
  * Supported top-level `wp-typia add` kinds exposed by the canonical CLI.
3
4
  */
@@ -8,6 +9,15 @@ export type AddKindId = (typeof ADD_KIND_IDS)[number];
8
9
  */
9
10
  export declare const ADD_BLOCK_TEMPLATE_IDS: readonly ["basic", "interactivity", "persistence", "compound"];
10
11
  export type AddBlockTemplateId = (typeof ADD_BLOCK_TEMPLATE_IDS)[number];
12
+ interface RunAddVariationCommandOptions {
13
+ blockName: string;
14
+ cwd?: string;
15
+ variationName: string;
16
+ }
17
+ interface RunAddPatternCommandOptions {
18
+ cwd?: string;
19
+ patternName: string;
20
+ }
11
21
  interface RunAddBlockCommandOptions {
12
22
  blockName: string;
13
23
  cwd?: string;
@@ -32,7 +42,43 @@ export declare function runAddBlockCommand({ blockName, cwd, dataStorageMode, pe
32
42
  templateId: AddBlockTemplateId;
33
43
  }>;
34
44
  /**
35
- * Returns the current placeholder guidance for unsupported `wp-typia add` kinds.
45
+ * Add one variation entry to an existing workspace block.
46
+ *
47
+ * @param options Command options for the variation scaffold workflow.
48
+ * @param options.blockName Target workspace block slug that will own the variation.
49
+ * @param options.cwd Working directory used to resolve the nearest official workspace.
50
+ * Defaults to `process.cwd()`.
51
+ * @param options.variationName Human-entered variation name that will be normalized
52
+ * and validated before files are written.
53
+ * @returns A promise that resolves with the normalized `blockSlug`,
54
+ * `variationSlug`, and owning `projectDir` after the variation files and
55
+ * inventory entry have been written successfully.
56
+ * @throws {Error} When the command is run outside an official workspace, when
57
+ * the target block is unknown, when the variation slug is invalid, or when a
58
+ * conflicting file or inventory entry already exists.
36
59
  */
37
- export declare function createAddPlaceholderMessage(kind: Exclude<AddKindId, "block">): string;
38
- export {};
60
+ export declare function runAddVariationCommand({ blockName, cwd, variationName, }: RunAddVariationCommandOptions): Promise<{
61
+ blockSlug: string;
62
+ projectDir: string;
63
+ variationSlug: string;
64
+ }>;
65
+ /**
66
+ * Add one PHP block pattern shell to an official workspace project.
67
+ *
68
+ * @param options Command options for the pattern scaffold workflow.
69
+ * @param options.cwd Working directory used to resolve the nearest official workspace.
70
+ * Defaults to `process.cwd()`.
71
+ * @param options.patternName Human-entered pattern name that will be normalized
72
+ * and validated before files are written.
73
+ * @returns A promise that resolves with the normalized `patternSlug` and
74
+ * owning `projectDir` after the pattern file and inventory entry have been
75
+ * written successfully.
76
+ * @throws {Error} When the command is run outside an official workspace, when
77
+ * the pattern slug is invalid, or when a conflicting file or inventory entry
78
+ * already exists.
79
+ */
80
+ export declare function runAddPatternCommand({ cwd, patternName, }: RunAddPatternCommandOptions): Promise<{
81
+ patternSlug: string;
82
+ projectDir: string;
83
+ }>;
84
+ export { getWorkspaceBlockSelectOptions };
@@ -8,7 +8,9 @@ import { snapshotProjectVersion } from "./migrations.js";
8
8
  import { getDefaultAnswers, scaffoldProject } from "./scaffold.js";
9
9
  import { SHARED_WORKSPACE_TEMPLATE_ROOT, } from "./template-registry.js";
10
10
  import { copyInterpolatedDirectory } from "./template-render.js";
11
- import { toKebabCase, toSnakeCase, } from "./string-case.js";
11
+ import { toKebabCase, toTitleCase, toSnakeCase, } from "./string-case.js";
12
+ import { appendWorkspaceInventoryEntries, getWorkspaceBlockSelectOptions, readWorkspaceInventory, } from "./workspace-inventory.js";
13
+ import { resolveWorkspaceProject, WORKSPACE_TEMPLATE_PACKAGE, } from "./workspace-project.js";
12
14
  /**
13
15
  * Supported top-level `wp-typia add` kinds exposed by the canonical CLI.
14
16
  */
@@ -22,26 +24,28 @@ export const ADD_BLOCK_TEMPLATE_IDS = [
22
24
  "persistence",
23
25
  "compound",
24
26
  ];
25
- const WORKSPACE_TEMPLATE_PACKAGE = "@wp-typia/create-workspace-template";
26
- const BLOCK_CONFIG_ENTRY_MARKER = "\t// wp-typia add block entries";
27
27
  const COLLECTION_IMPORT_LINE = "import '../../collection';";
28
- const EMPTY_BLOCKS_ARRAY = `${BLOCK_CONFIG_ENTRY_MARKER}\n];`;
29
28
  const REST_MANIFEST_IMPORT_PATTERN = /import\s*\{[^}]*\bdefineEndpointManifest\b[^}]*\}\s*from\s*["']@wp-typia\/block-runtime\/metadata-core["'];?/m;
30
- function parsePackageManagerId(packageManagerField) {
31
- const packageManagerId = packageManagerField?.split("@", 1)[0];
32
- switch (packageManagerId) {
33
- case "bun":
34
- case "npm":
35
- case "pnpm":
36
- case "yarn":
37
- return packageManagerId;
38
- default:
39
- return "bun";
40
- }
41
- }
29
+ const VARIATIONS_IMPORT_LINE = "import { registerWorkspaceVariations } from './variations';";
30
+ const VARIATIONS_CALL_LINE = "registerWorkspaceVariations();";
31
+ const PATTERN_BOOTSTRAP_CATEGORY = "register_block_pattern_category";
32
+ const WORKSPACE_GENERATED_SLUG_PATTERN = /^[a-z][a-z0-9-]*$/;
42
33
  function normalizeBlockSlug(input) {
43
34
  return toKebabCase(input);
44
35
  }
36
+ function assertValidGeneratedSlug(label, slug, usage) {
37
+ if (!slug) {
38
+ throw new Error(`${label} is required. Use \`${usage}\`.`);
39
+ }
40
+ if (!WORKSPACE_GENERATED_SLUG_PATTERN.test(slug)) {
41
+ throw new Error(`${label} must start with a letter and contain only lowercase letters, numbers, and hyphens.`);
42
+ }
43
+ return slug;
44
+ }
45
+ function getWorkspaceBootstrapPath(workspace) {
46
+ const workspaceBaseName = workspace.packageName.split("/").pop() ?? workspace.packageName;
47
+ return path.join(workspace.projectDir, `${workspaceBaseName}.php`);
48
+ }
45
49
  function buildWorkspacePhpPrefix(workspacePhpPrefix, slug) {
46
50
  return toSnakeCase(`${workspacePhpPrefix}_${slug}`);
47
51
  }
@@ -263,6 +267,205 @@ async function addCollectionImportsForTemplate(projectDir, templateId, variables
263
267
  }
264
268
  await ensureCollectionImport(path.join(projectDir, "src", "blocks", variables.slugKebabCase, "index.tsx"));
265
269
  }
270
+ function buildVariationConfigEntry(blockSlug, variationSlug) {
271
+ return [
272
+ "\t{",
273
+ `\t\tblock: ${quoteTsString(blockSlug)},`,
274
+ `\t\tfile: ${quoteTsString(`src/blocks/${blockSlug}/variations/${variationSlug}.ts`)},`,
275
+ `\t\tslug: ${quoteTsString(variationSlug)},`,
276
+ "\t},",
277
+ ].join("\n");
278
+ }
279
+ function buildPatternConfigEntry(patternSlug) {
280
+ return [
281
+ "\t{",
282
+ `\t\tfile: ${quoteTsString(`src/patterns/${patternSlug}.php`)},`,
283
+ `\t\tslug: ${quoteTsString(patternSlug)},`,
284
+ "\t},",
285
+ ].join("\n");
286
+ }
287
+ function buildVariationConstName(variationSlug) {
288
+ const identifierSegments = toKebabCase(variationSlug)
289
+ .split("-")
290
+ .filter(Boolean);
291
+ return `workspaceVariation_${identifierSegments.join("_")}`;
292
+ }
293
+ function getVariationConstBindings(variationSlugs) {
294
+ const seenConstNames = new Map();
295
+ return variationSlugs.map((variationSlug) => {
296
+ const constName = buildVariationConstName(variationSlug);
297
+ const previousSlug = seenConstNames.get(constName);
298
+ if (previousSlug && previousSlug !== variationSlug) {
299
+ throw new Error(`Variation slugs "${previousSlug}" and "${variationSlug}" generate the same registry identifier "${constName}". Rename one of the variations.`);
300
+ }
301
+ seenConstNames.set(constName, variationSlug);
302
+ return { constName, variationSlug };
303
+ });
304
+ }
305
+ function buildVariationSource(variationSlug, textDomain) {
306
+ const variationTitle = toTitleCase(variationSlug);
307
+ const variationConstName = buildVariationConstName(variationSlug);
308
+ return `import type { BlockVariation } from '@wordpress/blocks';
309
+ import { __ } from '@wordpress/i18n';
310
+
311
+ export const ${variationConstName} = {
312
+ \tname: ${quoteTsString(variationSlug)},
313
+ \ttitle: __( ${quoteTsString(variationTitle)}, ${quoteTsString(textDomain)} ),
314
+ \tdescription: __(
315
+ \t\t${quoteTsString(`A starter variation for ${variationTitle}.`)},
316
+ \t\t${quoteTsString(textDomain)},
317
+ \t),
318
+ \tattributes: {},
319
+ \tscope: ['inserter'],
320
+ } satisfies BlockVariation;
321
+ `;
322
+ }
323
+ function buildVariationIndexSource(variationSlugs) {
324
+ const variationBindings = getVariationConstBindings(variationSlugs);
325
+ const importLines = variationBindings
326
+ .map(({ constName, variationSlug }) => {
327
+ return `import { ${constName} } from './${variationSlug}';`;
328
+ })
329
+ .join("\n");
330
+ const variationConstNames = variationBindings
331
+ .map(({ constName }) => constName)
332
+ .join(",\n\t\t");
333
+ return `import { registerBlockVariation } from '@wordpress/blocks';
334
+ import metadata from '../block.json';
335
+ ${importLines ? `\n${importLines}` : ""}
336
+
337
+ const WORKSPACE_VARIATIONS = [
338
+ \t${variationConstNames}
339
+ \t// wp-typia add variation entries
340
+ ];
341
+
342
+ export function registerWorkspaceVariations() {
343
+ \tfor (const variation of WORKSPACE_VARIATIONS) {
344
+ \t\tregisterBlockVariation(metadata.name, variation);
345
+ \t}
346
+ }
347
+ `;
348
+ }
349
+ function buildPatternSource(patternSlug, namespace, textDomain) {
350
+ const patternTitle = toTitleCase(patternSlug);
351
+ return `<?php
352
+ if ( ! defined( 'ABSPATH' ) ) {
353
+ \treturn;
354
+ }
355
+
356
+ register_block_pattern(
357
+ \t'${namespace}/${patternSlug}',
358
+ \tarray(
359
+ \t\t'title' => __( ${JSON.stringify(patternTitle)}, '${textDomain}' ),
360
+ \t\t'description' => __( ${JSON.stringify(`A starter pattern for ${patternTitle}.`)}, '${textDomain}' ),
361
+ \t\t'categories' => array( '${namespace}' ),
362
+ \t\t'content' => '<!-- wp:paragraph --><p>' . esc_html__( 'Describe this pattern here.', '${textDomain}' ) . '</p><!-- /wp:paragraph -->',
363
+ \t)
364
+ );
365
+ `;
366
+ }
367
+ async function ensureVariationRegistrationHook(blockIndexPath) {
368
+ await patchFile(blockIndexPath, (source) => {
369
+ let nextSource = source;
370
+ if (!nextSource.includes(VARIATIONS_IMPORT_LINE)) {
371
+ nextSource = `${VARIATIONS_IMPORT_LINE}\n${nextSource}`;
372
+ }
373
+ if (!nextSource.includes(VARIATIONS_CALL_LINE)) {
374
+ const callInsertionPatterns = [
375
+ /(registerBlockType<[\s\S]*?\);\s*)/u,
376
+ /(registerBlockType\([\s\S]*?\);\s*)/u,
377
+ ];
378
+ let inserted = false;
379
+ for (const pattern of callInsertionPatterns) {
380
+ const candidate = nextSource.replace(pattern, (match) => `${match}\n${VARIATIONS_CALL_LINE}\n`);
381
+ if (candidate !== nextSource) {
382
+ nextSource = candidate;
383
+ inserted = true;
384
+ break;
385
+ }
386
+ }
387
+ if (!inserted) {
388
+ nextSource = `${nextSource.trimEnd()}\n\n${VARIATIONS_CALL_LINE}\n`;
389
+ }
390
+ }
391
+ if (!nextSource.includes(VARIATIONS_CALL_LINE)) {
392
+ throw new Error(`Unable to inject ${VARIATIONS_CALL_LINE} into ${path.basename(blockIndexPath)}.`);
393
+ }
394
+ return nextSource;
395
+ });
396
+ }
397
+ async function writeVariationRegistry(projectDir, blockSlug, variationSlug) {
398
+ const variationsDir = path.join(projectDir, "src", "blocks", blockSlug, "variations");
399
+ const variationsIndexPath = path.join(variationsDir, "index.ts");
400
+ await fsp.mkdir(variationsDir, { recursive: true });
401
+ const existingVariationSlugs = fs.existsSync(variationsDir)
402
+ ? fs
403
+ .readdirSync(variationsDir)
404
+ .filter((entry) => entry.endsWith(".ts") && entry !== "index.ts")
405
+ .map((entry) => entry.replace(/\.ts$/u, ""))
406
+ : [];
407
+ const nextVariationSlugs = Array.from(new Set([...existingVariationSlugs, variationSlug])).sort();
408
+ await fsp.writeFile(variationsIndexPath, buildVariationIndexSource(nextVariationSlugs), "utf8");
409
+ }
410
+ async function ensurePatternBootstrapAnchors(workspace) {
411
+ const workspaceBaseName = workspace.packageName.split("/").pop() ?? workspace.packageName;
412
+ const bootstrapPath = getWorkspaceBootstrapPath(workspace);
413
+ await patchFile(bootstrapPath, (source) => {
414
+ let nextSource = source;
415
+ const patternCategoryFunctionName = `${workspace.workspace.phpPrefix}_register_pattern_category`;
416
+ const patternRegistrationFunctionName = `${workspace.workspace.phpPrefix}_register_patterns`;
417
+ const patternCategoryHook = `add_action( 'init', '${patternCategoryFunctionName}' );`;
418
+ const patternRegistrationHook = `add_action( 'init', '${patternRegistrationFunctionName}', 20 );`;
419
+ const patternFunctions = `
420
+
421
+ function ${patternCategoryFunctionName}() {
422
+ \tif ( function_exists( 'register_block_pattern_category' ) ) {
423
+ \t\tregister_block_pattern_category(
424
+ \t\t\t'${workspace.workspace.namespace}',
425
+ \t\t\tarray(
426
+ \t\t\t\t'label' => __( ${JSON.stringify(`${toTitleCase(workspaceBaseName)} Patterns`)}, '${workspace.workspace.textDomain}' ),
427
+ \t\t\t)
428
+ \t\t);
429
+ \t}
430
+ }
431
+
432
+ function ${patternRegistrationFunctionName}() {
433
+ \tforeach ( glob( __DIR__ . '/src/patterns/*.php' ) ?: array() as $pattern_module ) {
434
+ \t\trequire $pattern_module;
435
+ \t}
436
+ }
437
+ `;
438
+ if (!nextSource.includes(PATTERN_BOOTSTRAP_CATEGORY)) {
439
+ const insertionAnchors = [
440
+ /add_action\(\s*["']init["']\s*,\s*["'][^"']+_load_textdomain["']\s*\);\s*\n/u,
441
+ /\?>\s*$/u,
442
+ ];
443
+ let inserted = false;
444
+ for (const anchor of insertionAnchors) {
445
+ const candidate = nextSource.replace(anchor, (match) => `${patternFunctions}\n${match}`);
446
+ if (candidate !== nextSource) {
447
+ nextSource = candidate;
448
+ inserted = true;
449
+ break;
450
+ }
451
+ }
452
+ if (!inserted) {
453
+ nextSource = `${nextSource.trimEnd()}\n${patternFunctions}\n`;
454
+ }
455
+ }
456
+ if (!nextSource.includes(patternCategoryFunctionName) ||
457
+ !nextSource.includes(patternRegistrationFunctionName)) {
458
+ throw new Error(`Unable to inject pattern bootstrap functions into ${path.basename(bootstrapPath)}.`);
459
+ }
460
+ if (!nextSource.includes(patternCategoryHook)) {
461
+ nextSource = `${nextSource.trimEnd()}\n${patternCategoryHook}\n`;
462
+ }
463
+ if (!nextSource.includes(patternRegistrationHook)) {
464
+ nextSource = `${nextSource.trimEnd()}\n${patternRegistrationHook}\n`;
465
+ }
466
+ return nextSource;
467
+ });
468
+ }
266
469
  function ensureBlockConfigCanAddRestManifests(source) {
267
470
  const importLine = "import { defineEndpointManifest } from '@wp-typia/block-runtime/metadata-core';";
268
471
  if (REST_MANIFEST_IMPORT_PATTERN.test(source)) {
@@ -271,21 +474,18 @@ function ensureBlockConfigCanAddRestManifests(source) {
271
474
  return `${importLine}\n\n${source}`;
272
475
  }
273
476
  async function appendBlockConfigEntries(projectDir, entries, needsRestManifestImport) {
274
- const blockConfigPath = path.join(projectDir, "scripts", "block-config.ts");
275
- await patchFile(blockConfigPath, (source) => {
276
- let nextSource = source;
277
- if (needsRestManifestImport) {
278
- nextSource = ensureBlockConfigCanAddRestManifests(nextSource);
279
- }
280
- if (nextSource.includes(BLOCK_CONFIG_ENTRY_MARKER)) {
281
- return nextSource.replace(BLOCK_CONFIG_ENTRY_MARKER, `${entries.join("\n")}\n${BLOCK_CONFIG_ENTRY_MARKER}`);
282
- }
283
- if (nextSource.includes(EMPTY_BLOCKS_ARRAY)) {
284
- return nextSource.replace(EMPTY_BLOCKS_ARRAY, `${entries.join("\n")}\n];`);
285
- }
286
- return nextSource.replace("];", `${entries.join("\n")}\n];`);
477
+ await appendWorkspaceInventoryEntries(projectDir, {
478
+ blockEntries: entries,
479
+ transformSource: needsRestManifestImport ? ensureBlockConfigCanAddRestManifests : undefined,
287
480
  });
288
481
  }
482
+ async function snapshotWorkspaceFiles(filePaths) {
483
+ const uniquePaths = Array.from(new Set(filePaths));
484
+ return Promise.all(uniquePaths.map(async (filePath) => ({
485
+ filePath,
486
+ source: await readOptionalFile(filePath),
487
+ })));
488
+ }
289
489
  async function renderWorkspacePersistenceServerModule(projectDir, variables) {
290
490
  const targetDir = path.join(projectDir, "src", "blocks", variables.slugKebabCase);
291
491
  const templateDir = buildServerTemplateRoot(variables.persistencePolicy);
@@ -387,39 +587,6 @@ async function syncWorkspaceAddedBlockArtifacts(projectDir, templateId, variable
387
587
  await syncWorkspacePersistenceArtifacts(projectDir, variables);
388
588
  }
389
589
  }
390
- function resolveWorkspaceProject(startDir) {
391
- let currentDir = path.resolve(startDir);
392
- while (true) {
393
- const packageJsonPath = path.join(currentDir, "package.json");
394
- if (fs.existsSync(packageJsonPath)) {
395
- const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
396
- if (packageJson.wpTypia?.projectType === "workspace" &&
397
- packageJson.wpTypia?.templatePackage === WORKSPACE_TEMPLATE_PACKAGE &&
398
- typeof packageJson.wpTypia.namespace === "string" &&
399
- typeof packageJson.wpTypia.textDomain === "string" &&
400
- typeof packageJson.wpTypia.phpPrefix === "string") {
401
- return {
402
- author: typeof packageJson.author === "string" ? packageJson.author : "Your Name",
403
- packageManager: parsePackageManagerId(packageJson.packageManager),
404
- projectDir: currentDir,
405
- workspace: {
406
- namespace: packageJson.wpTypia.namespace,
407
- phpPrefix: packageJson.wpTypia.phpPrefix,
408
- projectType: "workspace",
409
- templatePackage: WORKSPACE_TEMPLATE_PACKAGE,
410
- textDomain: packageJson.wpTypia.textDomain,
411
- },
412
- };
413
- }
414
- }
415
- const parentDir = path.dirname(currentDir);
416
- if (parentDir === currentDir) {
417
- break;
418
- }
419
- currentDir = parentDir;
420
- }
421
- throw new Error(`This command must run inside a ${WORKSPACE_TEMPLATE_PACKAGE} project. Create one with \`wp-typia create my-plugin --template ${WORKSPACE_TEMPLATE_PACKAGE}\` first.`);
422
- }
423
590
  function assertPersistenceFlagsAllowed(templateId, options) {
424
591
  const hasPersistenceFlags = typeof options.dataStorageMode === "string" ||
425
592
  typeof options.persistencePolicy === "string";
@@ -447,12 +614,13 @@ function assertPersistenceFlagsAllowed(templateId, options) {
447
614
  export function formatAddHelpText() {
448
615
  return `Usage:
449
616
  wp-typia add block <name> --template <${ADD_BLOCK_TEMPLATE_IDS.join("|")}> [--data-storage <post-meta|custom-table>] [--persistence-policy <authenticated|public>]
450
- wp-typia add variation
451
- wp-typia add pattern
617
+ wp-typia add variation <name> --block <block-slug>
618
+ wp-typia add pattern <name>
452
619
 
453
620
  Notes:
454
- \`wp-typia add block\` runs only inside official ${WORKSPACE_TEMPLATE_PACKAGE} workspaces.
455
- \`wp-typia add variation\` and \`wp-typia add pattern\` are reserved placeholders for follow-up workflows.`;
621
+ \`wp-typia add\` runs only inside official ${WORKSPACE_TEMPLATE_PACKAGE} workspaces.
622
+ \`add variation\` targets an existing block slug from \`scripts/block-config.ts\`.
623
+ \`add pattern\` scaffolds a namespaced PHP pattern shell under \`src/patterns/\`.`;
456
624
  }
457
625
  /**
458
626
  * Seeds an empty official workspace migration project before any blocks are added.
@@ -467,15 +635,16 @@ export async function seedWorkspaceMigrationProject(projectDir, currentMigration
467
635
  ensureMigrationDirectories(projectDir, []);
468
636
  writeInitialMigrationScaffold(projectDir, currentMigrationVersion, []);
469
637
  }
470
- async function rollbackWorkspaceMutation(projectDir, snapshot) {
638
+ async function rollbackWorkspaceMutation(snapshot) {
471
639
  for (const targetPath of snapshot.targetPaths) {
472
640
  await fsp.rm(targetPath, { force: true, recursive: true });
473
641
  }
474
642
  for (const snapshotDir of snapshot.snapshotDirs) {
475
643
  await fsp.rm(snapshotDir, { force: true, recursive: true });
476
644
  }
477
- await restoreOptionalFile(path.join(projectDir, "scripts", "block-config.ts"), snapshot.blockConfigSource);
478
- await restoreOptionalFile(path.join(projectDir, "src", "migrations", "config.ts"), snapshot.migrationConfigSource);
645
+ for (const { filePath, source } of snapshot.fileSources) {
646
+ await restoreOptionalFile(filePath, source);
647
+ }
479
648
  }
480
649
  /**
481
650
  * Adds one built-in block slice to an official workspace project.
@@ -496,9 +665,10 @@ export async function runAddBlockCommand({ blockName, cwd = process.cwd(), dataS
496
665
  try {
497
666
  tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "wp-typia-add-block-"));
498
667
  const tempProjectDir = path.join(tempRoot, normalizedSlug);
668
+ const blockConfigPath = path.join(workspace.projectDir, "scripts", "block-config.ts");
669
+ const migrationConfigPath = path.join(workspace.projectDir, "src", "migrations", "config.ts");
499
670
  const blockPhpPrefix = buildWorkspacePhpPrefix(workspace.workspace.phpPrefix, normalizedSlug);
500
- const blockConfigSource = await readOptionalFile(path.join(workspace.projectDir, "scripts", "block-config.ts"));
501
- const migrationConfigSource = await readOptionalFile(path.join(workspace.projectDir, "src", "migrations", "config.ts"));
671
+ const migrationConfigSource = await readOptionalFile(migrationConfigPath);
502
672
  const migrationConfig = migrationConfigSource === null ? null : parseMigrationConfig(migrationConfigSource);
503
673
  const result = await scaffoldProject({
504
674
  answers: {
@@ -520,8 +690,7 @@ export async function runAddBlockCommand({ blockName, cwd = process.cwd(), dataS
520
690
  });
521
691
  assertBlockTargetsDoNotExist(workspace.projectDir, resolvedTemplateId, result.variables);
522
692
  const mutationSnapshot = {
523
- blockConfigSource,
524
- migrationConfigSource,
693
+ fileSources: await snapshotWorkspaceFiles([blockConfigPath, migrationConfigPath]),
525
694
  snapshotDirs: migrationConfig === null
526
695
  ? []
527
696
  : buildMigrationBlocks(resolvedTemplateId, result.variables).map((block) => path.join(workspace.projectDir, ...migrationConfig.snapshotDir.split("/"), migrationConfig.currentMigrationVersion, block.key)),
@@ -542,7 +711,7 @@ export async function runAddBlockCommand({ blockName, cwd = process.cwd(), dataS
542
711
  };
543
712
  }
544
713
  catch (error) {
545
- await rollbackWorkspaceMutation(workspace.projectDir, mutationSnapshot);
714
+ await rollbackWorkspaceMutation(mutationSnapshot);
546
715
  throw error;
547
716
  }
548
717
  }
@@ -552,10 +721,130 @@ export async function runAddBlockCommand({ blockName, cwd = process.cwd(), dataS
552
721
  }
553
722
  }
554
723
  }
724
+ function resolveWorkspaceBlock(inventory, blockSlug) {
725
+ const block = inventory.blocks.find((entry) => entry.slug === blockSlug);
726
+ if (!block) {
727
+ throw new Error(`Unknown workspace block "${blockSlug}". Choose one of: ${inventory.blocks.map((entry) => entry.slug).join(", ")}`);
728
+ }
729
+ return block;
730
+ }
731
+ function assertVariationDoesNotExist(projectDir, blockSlug, variationSlug, inventory) {
732
+ const variationPath = path.join(projectDir, "src", "blocks", blockSlug, "variations", `${variationSlug}.ts`);
733
+ if (fs.existsSync(variationPath)) {
734
+ throw new Error(`A variation already exists at ${path.relative(projectDir, variationPath)}. Choose a different name.`);
735
+ }
736
+ if (inventory.variations.some((entry) => entry.block === blockSlug && entry.slug === variationSlug)) {
737
+ throw new Error(`A variation inventory entry already exists for ${blockSlug}/${variationSlug}. Choose a different name.`);
738
+ }
739
+ }
740
+ function assertPatternDoesNotExist(projectDir, patternSlug, inventory) {
741
+ const patternPath = path.join(projectDir, "src", "patterns", `${patternSlug}.php`);
742
+ if (fs.existsSync(patternPath)) {
743
+ throw new Error(`A pattern already exists at ${path.relative(projectDir, patternPath)}. Choose a different name.`);
744
+ }
745
+ if (inventory.patterns.some((entry) => entry.slug === patternSlug)) {
746
+ throw new Error(`A pattern inventory entry already exists for ${patternSlug}. Choose a different name.`);
747
+ }
748
+ }
749
+ /**
750
+ * Add one variation entry to an existing workspace block.
751
+ *
752
+ * @param options Command options for the variation scaffold workflow.
753
+ * @param options.blockName Target workspace block slug that will own the variation.
754
+ * @param options.cwd Working directory used to resolve the nearest official workspace.
755
+ * Defaults to `process.cwd()`.
756
+ * @param options.variationName Human-entered variation name that will be normalized
757
+ * and validated before files are written.
758
+ * @returns A promise that resolves with the normalized `blockSlug`,
759
+ * `variationSlug`, and owning `projectDir` after the variation files and
760
+ * inventory entry have been written successfully.
761
+ * @throws {Error} When the command is run outside an official workspace, when
762
+ * the target block is unknown, when the variation slug is invalid, or when a
763
+ * conflicting file or inventory entry already exists.
764
+ */
765
+ export async function runAddVariationCommand({ blockName, cwd = process.cwd(), variationName, }) {
766
+ const workspace = resolveWorkspaceProject(cwd);
767
+ const blockSlug = normalizeBlockSlug(blockName);
768
+ const variationSlug = assertValidGeneratedSlug("Variation name", normalizeBlockSlug(variationName), "wp-typia add variation <name> --block <block-slug>");
769
+ const inventory = readWorkspaceInventory(workspace.projectDir);
770
+ resolveWorkspaceBlock(inventory, blockSlug);
771
+ assertVariationDoesNotExist(workspace.projectDir, blockSlug, variationSlug, inventory);
772
+ const blockConfigPath = path.join(workspace.projectDir, "scripts", "block-config.ts");
773
+ const blockIndexPath = path.join(workspace.projectDir, "src", "blocks", blockSlug, "index.tsx");
774
+ const variationsDir = path.join(workspace.projectDir, "src", "blocks", blockSlug, "variations");
775
+ const variationFilePath = path.join(variationsDir, `${variationSlug}.ts`);
776
+ const variationsIndexPath = path.join(variationsDir, "index.ts");
777
+ const mutationSnapshot = {
778
+ fileSources: await snapshotWorkspaceFiles([
779
+ blockConfigPath,
780
+ blockIndexPath,
781
+ variationsIndexPath,
782
+ ]),
783
+ snapshotDirs: [],
784
+ targetPaths: [variationFilePath],
785
+ };
786
+ try {
787
+ await fsp.mkdir(variationsDir, { recursive: true });
788
+ await fsp.writeFile(variationFilePath, buildVariationSource(variationSlug, workspace.workspace.textDomain), "utf8");
789
+ await writeVariationRegistry(workspace.projectDir, blockSlug, variationSlug);
790
+ await ensureVariationRegistrationHook(blockIndexPath);
791
+ await appendWorkspaceInventoryEntries(workspace.projectDir, {
792
+ variationEntries: [buildVariationConfigEntry(blockSlug, variationSlug)],
793
+ });
794
+ return {
795
+ blockSlug,
796
+ projectDir: workspace.projectDir,
797
+ variationSlug,
798
+ };
799
+ }
800
+ catch (error) {
801
+ await rollbackWorkspaceMutation(mutationSnapshot);
802
+ throw error;
803
+ }
804
+ }
555
805
  /**
556
- * Returns the current placeholder guidance for unsupported `wp-typia add` kinds.
806
+ * Add one PHP block pattern shell to an official workspace project.
807
+ *
808
+ * @param options Command options for the pattern scaffold workflow.
809
+ * @param options.cwd Working directory used to resolve the nearest official workspace.
810
+ * Defaults to `process.cwd()`.
811
+ * @param options.patternName Human-entered pattern name that will be normalized
812
+ * and validated before files are written.
813
+ * @returns A promise that resolves with the normalized `patternSlug` and
814
+ * owning `projectDir` after the pattern file and inventory entry have been
815
+ * written successfully.
816
+ * @throws {Error} When the command is run outside an official workspace, when
817
+ * the pattern slug is invalid, or when a conflicting file or inventory entry
818
+ * already exists.
557
819
  */
558
- export function createAddPlaceholderMessage(kind) {
559
- const issueNumber = kind === "variation" ? "#157" : "#158";
560
- return `\`wp-typia add ${kind}\` is not implemented yet. Track ${issueNumber} for the first supported ${kind} workflow.`;
820
+ export async function runAddPatternCommand({ cwd = process.cwd(), patternName, }) {
821
+ const workspace = resolveWorkspaceProject(cwd);
822
+ const patternSlug = assertValidGeneratedSlug("Pattern name", normalizeBlockSlug(patternName), "wp-typia add pattern <name>");
823
+ const inventory = readWorkspaceInventory(workspace.projectDir);
824
+ assertPatternDoesNotExist(workspace.projectDir, patternSlug, inventory);
825
+ const blockConfigPath = path.join(workspace.projectDir, "scripts", "block-config.ts");
826
+ const bootstrapPath = getWorkspaceBootstrapPath(workspace);
827
+ const patternFilePath = path.join(workspace.projectDir, "src", "patterns", `${patternSlug}.php`);
828
+ const mutationSnapshot = {
829
+ fileSources: await snapshotWorkspaceFiles([blockConfigPath, bootstrapPath]),
830
+ snapshotDirs: [],
831
+ targetPaths: [patternFilePath],
832
+ };
833
+ try {
834
+ await fsp.mkdir(path.dirname(patternFilePath), { recursive: true });
835
+ await ensurePatternBootstrapAnchors(workspace);
836
+ await fsp.writeFile(patternFilePath, buildPatternSource(patternSlug, workspace.workspace.namespace, workspace.workspace.textDomain), "utf8");
837
+ await appendWorkspaceInventoryEntries(workspace.projectDir, {
838
+ patternEntries: [buildPatternConfigEntry(patternSlug)],
839
+ });
840
+ return {
841
+ patternSlug,
842
+ projectDir: workspace.projectDir,
843
+ };
844
+ }
845
+ catch (error) {
846
+ await rollbackWorkspaceMutation(mutationSnapshot);
847
+ throw error;
848
+ }
561
849
  }
850
+ export { getWorkspaceBlockSelectOptions };
@@ -6,7 +6,8 @@
6
6
  * `packages/wp-typia`.
7
7
  *
8
8
  * Import `formatAddHelpText`, `runAddBlockCommand`,
9
- * `createAddPlaceholderMessage`, and `seedWorkspaceMigrationProject` for
9
+ * `runAddVariationCommand`, `runAddPatternCommand`,
10
+ * `getWorkspaceBlockSelectOptions`, and `seedWorkspaceMigrationProject` for
10
11
  * explicit `wp-typia add` flows,
11
12
  * `getDoctorChecks`, `runDoctor`, and `DoctorCheck` for diagnostics,
12
13
  * `formatHelpText` for top-level CLI usage output, scaffold helpers such as
@@ -18,7 +19,7 @@
18
19
  * template inspection flows.
19
20
  */
20
21
  export { getDoctorChecks, runDoctor, type DoctorCheck } from "./cli-doctor.js";
21
- export { createAddPlaceholderMessage, formatAddHelpText, runAddBlockCommand, seedWorkspaceMigrationProject } from "./cli-add.js";
22
+ export { formatAddHelpText, getWorkspaceBlockSelectOptions, runAddBlockCommand, runAddPatternCommand, runAddVariationCommand, seedWorkspaceMigrationProject, } from "./cli-add.js";
22
23
  export { formatHelpText } from "./cli-help.js";
23
24
  export { getNextSteps, getOptionalOnboarding, runScaffoldFlow, } from "./cli-scaffold.js";
24
25
  export { createReadlinePrompt, type ReadlinePrompt } from "./cli-prompt.js";
@@ -6,7 +6,8 @@
6
6
  * `packages/wp-typia`.
7
7
  *
8
8
  * Import `formatAddHelpText`, `runAddBlockCommand`,
9
- * `createAddPlaceholderMessage`, and `seedWorkspaceMigrationProject` for
9
+ * `runAddVariationCommand`, `runAddPatternCommand`,
10
+ * `getWorkspaceBlockSelectOptions`, and `seedWorkspaceMigrationProject` for
10
11
  * explicit `wp-typia add` flows,
11
12
  * `getDoctorChecks`, `runDoctor`, and `DoctorCheck` for diagnostics,
12
13
  * `formatHelpText` for top-level CLI usage output, scaffold helpers such as
@@ -18,7 +19,7 @@
18
19
  * template inspection flows.
19
20
  */
20
21
  export { getDoctorChecks, runDoctor } from "./cli-doctor.js";
21
- export { createAddPlaceholderMessage, formatAddHelpText, runAddBlockCommand, seedWorkspaceMigrationProject } from "./cli-add.js";
22
+ export { formatAddHelpText, getWorkspaceBlockSelectOptions, runAddBlockCommand, runAddPatternCommand, runAddVariationCommand, seedWorkspaceMigrationProject, } from "./cli-add.js";
22
23
  export { formatHelpText } from "./cli-help.js";
23
24
  export { getNextSteps, getOptionalOnboarding, runScaffoldFlow, } from "./cli-scaffold.js";
24
25
  export { createReadlinePrompt } from "./cli-prompt.js";