@wp-typia/project-tools 0.24.0 → 0.24.1

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.
@@ -14,7 +14,7 @@ export function formatAddHelpText() {
14
14
  wp-typia add variation <name> --block <block-slug> [--dry-run]
15
15
  wp-typia add style <name> --block <block-slug> [--dry-run]
16
16
  wp-typia add transform <name> --from <namespace/block> --to <block-slug|namespace/block-slug> [--dry-run]
17
- wp-typia add pattern <name> [--scope <full|section>] [--section-role <role>] [--tags <tag,...>] [--thumbnail-url <url>] [--dry-run]
17
+ wp-typia add pattern <name> [--scope <full|section>] [--section-role <role>] [--catalog-title <title>] [--tags <tag,...>|--tag <tag>...] [--thumbnail-url <url>] [--dry-run]
18
18
  wp-typia add binding-source <name> [--block <block-slug|namespace/block-slug> --attribute <attribute>] [--from-post-meta|--post-meta <post-meta> [--meta-path <field>]] [--dry-run]
19
19
  wp-typia add contract <name> [--type <ExportedTypeName>] [--dry-run]
20
20
  wp-typia add rest-resource <name> [--namespace <vendor/v1>] [--methods <${REST_RESOURCE_METHOD_IDS.join(",")}>] [--route-pattern <route-pattern>] [--permission-callback <callback>] [--controller-class <ClassName>] [--controller-extends <BaseClass>] [--dry-run]
@@ -30,6 +30,7 @@ Notes:
30
30
  Pass \`--dry-run\` to preview the workspace files that would change without writing them.
31
31
  Interactive add flows let you choose a template when \`--template\` is omitted; non-interactive runs default to \`basic\`.
32
32
  \`add core-variation\` registers editor-side variations for existing core or external blocks without generating block.json or Typia manifests.
33
+ Unknown \`core/*\` core-variation targets warn instead of failing so newer WordPress core blocks remain scaffoldable; third-party namespaces are allowed without local discovery.
33
34
  \`add admin-view\` scaffolds an opt-in DataViews-powered WordPress admin screen under \`src/admin-views/\`.
34
35
  Pass \`--source rest-resource:<slug>\` to reuse a list-capable REST resource.
35
36
  Pass \`--source core-data:postType/post\` or \`--source core-data:taxonomy/category\` to bind a WordPress-owned entity collection.
@@ -41,7 +42,7 @@ Notes:
41
42
  \`add variation\` targets an existing block slug from \`scripts/block-config.ts\`.
42
43
  \`add style\` registers a Block Styles option for an existing generated block.
43
44
  \`add transform\` adds a block-to-block transform into an existing generated block.
44
- \`add pattern\` scaffolds a namespaced PHP pattern shell under \`src/patterns/full/\` or \`src/patterns/sections/\` and records typed catalog metadata in \`PATTERNS\`.
45
+ \`add pattern\` scaffolds a namespaced PHP pattern shell under \`src/patterns/full/\` or \`src/patterns/sections/\` and records typed catalog metadata in \`PATTERNS\`; pass \`--catalog-title "Hero Photo"\` to override the generated catalog title, and pass \`--tags hero,landing\` or repeat \`--tag hero --tag landing\` to set catalog tags.
45
46
  \`add binding-source\` scaffolds shared PHP and editor registration under \`src/bindings/\`; pass \`--block\` and \`--attribute\` together to declare an end-to-end bindable attribute on an existing generated block. Pass \`--from-post-meta\` or \`--post-meta\` to generate a source backed by a typed post-meta contract; \`--meta-path\` selects one top-level field as the default binding arg.
46
47
  \`add contract\` registers a standalone TypeScript wire contract under \`src/contracts/\` and generates a stable JSON Schema artifact without creating PHP route glue.
47
48
  \`add rest-resource\` scaffolds plugin-level TypeScript REST contracts under \`src/rest/\` and PHP route glue under \`inc/rest/\`. Use \`--route-pattern\`, \`--permission-callback\`, \`--controller-class\`, and \`--controller-extends\` when an existing WordPress controller or permission model needs to own part of the generated route surface.
@@ -1,14 +1,9 @@
1
1
  import { quoteTsString } from "./cli-add-shared.js";
2
2
  import { buildAiFeatureEndpointManifest } from "./ai-feature-artifacts.js";
3
3
  import { OPTIONAL_WORDPRESS_AI_CLIENT_COMPATIBILITY, createScaffoldCompatibilityConfig, renderScaffoldCompatibilityConfig, resolveScaffoldCompatibilityPolicy, } from "./scaffold-compatibility.js";
4
+ import { formatResolveRestNonceSource, indentMultiline, } from "./cli-add-workspace-rest-source-utils.js";
4
5
  import { toPascalCase, toTitleCase } from "./string-case.js";
5
6
  export { buildAiFeatureSyncScriptSource, } from "./cli-add-workspace-ai-sync-script-source.js";
6
- function indentMultiline(source, prefix) {
7
- return source
8
- .split("\n")
9
- .map((line) => `${prefix}${line}`)
10
- .join("\n");
11
- }
12
7
  /**
13
8
  * Build the workspace inventory entry written into `scripts/block-config.ts` for one AI feature.
14
9
  */
@@ -175,26 +170,7 @@ import {
175
170
  \trun${pascalCase}AiFeatureEndpoint,
176
171
  } from './api-client';
177
172
 
178
- function resolveRestNonce( fallback?: string ): string | undefined {
179
- \tif ( typeof fallback === 'string' && fallback.length > 0 ) {
180
- \t\treturn fallback;
181
- \t}
182
-
183
- \tif ( typeof window === 'undefined' ) {
184
- \t\treturn undefined;
185
- \t}
186
-
187
- \tconst wpApiSettings = (
188
- \t\twindow as typeof window & {
189
- \t\t\twpApiSettings?: { nonce?: string };
190
- \t\t}
191
- \t).wpApiSettings;
192
-
193
- \treturn typeof wpApiSettings?.nonce === 'string' &&
194
- \t\twpApiSettings.nonce.length > 0
195
- \t\t? wpApiSettings.nonce
196
- \t\t: undefined;
197
- }
173
+ ${formatResolveRestNonceSource("spaced")}
198
174
 
199
175
  function isPlainObject( value: unknown ): value is Record< string, unknown > {
200
176
  \treturn (
@@ -7,7 +7,8 @@ import { type RunAddCoreVariationCommandOptions } from "./cli-add-shared.js";
7
7
  * Defaults to `process.cwd()`.
8
8
  * @param options.targetBlockName Full `namespace/block` name that receives the variation.
9
9
  * @param options.variationName Human-entered variation name normalized into the generated slug.
10
- * @returns The normalized variation metadata and owning workspace directory.
10
+ * @returns The normalized variation metadata, owning workspace directory, and
11
+ * optional warnings for suspicious but forward-compatible targets.
11
12
  * @throws {Error} When the command is run outside an official workspace, the
12
13
  * target block name is not full `namespace/block` form, or the generated file
13
14
  * already exists.
@@ -17,4 +18,5 @@ export declare function runAddCoreVariationCommand({ cwd, targetBlockName, varia
17
18
  targetBlockName: string;
18
19
  variationFile: string;
19
20
  variationSlug: string;
21
+ warnings?: string[];
20
22
  }>;
@@ -8,6 +8,101 @@ import { toKebabCase, toPascalCase, toTitleCase } from "./string-case.js";
8
8
  import { resolveWorkspaceProject } from "./workspace-project.js";
9
9
  const CORE_VARIATIONS_EDITOR_PLUGIN_SLUG = "core-variations";
10
10
  const CORE_VARIATION_USAGE = "wp-typia add core-variation <block-name> <name> or wp-typia add core-variation <name> --block <namespace/block>";
11
+ const KNOWN_CORE_VARIATION_TARGETS = new Set([
12
+ "core/archives",
13
+ "core/audio",
14
+ "core/avatar",
15
+ "core/block",
16
+ "core/button",
17
+ "core/buttons",
18
+ "core/calendar",
19
+ "core/categories",
20
+ "core/code",
21
+ "core/column",
22
+ "core/columns",
23
+ "core/comment-author-name",
24
+ "core/comment-content",
25
+ "core/comment-date",
26
+ "core/comment-edit-link",
27
+ "core/comment-reply-link",
28
+ "core/comment-template",
29
+ "core/comments",
30
+ "core/comments-pagination",
31
+ "core/comments-pagination-next",
32
+ "core/comments-pagination-numbers",
33
+ "core/comments-pagination-previous",
34
+ "core/comments-title",
35
+ "core/cover",
36
+ "core/details",
37
+ "core/embed",
38
+ "core/file",
39
+ "core/footnotes",
40
+ "core/freeform",
41
+ "core/gallery",
42
+ "core/group",
43
+ "core/heading",
44
+ "core/home-link",
45
+ "core/html",
46
+ "core/image",
47
+ "core/latest-comments",
48
+ "core/latest-posts",
49
+ "core/legacy-widget",
50
+ "core/list",
51
+ "core/list-item",
52
+ "core/loginout",
53
+ "core/media-text",
54
+ "core/missing",
55
+ "core/more",
56
+ "core/navigation",
57
+ "core/navigation-link",
58
+ "core/navigation-submenu",
59
+ "core/nextpage",
60
+ "core/page-list",
61
+ "core/paragraph",
62
+ "core/pattern",
63
+ "core/post-author",
64
+ "core/post-author-biography",
65
+ "core/post-author-name",
66
+ "core/post-comments",
67
+ "core/post-comments-form",
68
+ "core/post-content",
69
+ "core/post-date",
70
+ "core/post-excerpt",
71
+ "core/post-featured-image",
72
+ "core/post-navigation-link",
73
+ "core/post-terms",
74
+ "core/post-template",
75
+ "core/post-title",
76
+ "core/preformatted",
77
+ "core/pullquote",
78
+ "core/query",
79
+ "core/query-no-results",
80
+ "core/query-pagination",
81
+ "core/query-pagination-next",
82
+ "core/query-pagination-numbers",
83
+ "core/query-pagination-previous",
84
+ "core/query-title",
85
+ "core/quote",
86
+ "core/read-more",
87
+ "core/rss",
88
+ "core/search",
89
+ "core/separator",
90
+ "core/shortcode",
91
+ "core/site-logo",
92
+ "core/site-tagline",
93
+ "core/site-title",
94
+ "core/social-link",
95
+ "core/social-links",
96
+ "core/spacer",
97
+ "core/table",
98
+ "core/table-of-contents",
99
+ "core/tag-cloud",
100
+ "core/template-part",
101
+ "core/term-description",
102
+ "core/text-columns",
103
+ "core/verse",
104
+ "core/video",
105
+ ]);
11
106
  const CORE_VARIATION_SIMPLE_CONTAINER_BLOCKS = new Set([
12
107
  "core/column",
13
108
  "core/cover",
@@ -54,6 +149,13 @@ function buildCoreVariationImportPath(ref) {
54
149
  function formatCoreVariationTitle(variationSlug) {
55
150
  return toTitleCase(variationSlug);
56
151
  }
152
+ function getUnknownCoreVariationTargetWarning(targetBlockName) {
153
+ if (!targetBlockName.startsWith("core/") ||
154
+ KNOWN_CORE_VARIATION_TARGETS.has(targetBlockName)) {
155
+ return undefined;
156
+ }
157
+ return `Target block "${targetBlockName}" uses the WordPress core namespace but is not in wp-typia's known core block list. The variation was generated for forward compatibility; verify the block name or update wp-typia if this is a newer core block.`;
158
+ }
57
159
  function assertCoreVariationDoesNotExist(projectDir, targetBlockName, variationSlug) {
58
160
  const variationFilePath = getCoreVariationFilePath(projectDir, targetBlockName, variationSlug);
59
161
  if (fs.existsSync(variationFilePath)) {
@@ -261,7 +363,8 @@ async function writeCoreVariationRegistry(projectDir, targetBlockName, textDomai
261
363
  * Defaults to `process.cwd()`.
262
364
  * @param options.targetBlockName Full `namespace/block` name that receives the variation.
263
365
  * @param options.variationName Human-entered variation name normalized into the generated slug.
264
- * @returns The normalized variation metadata and owning workspace directory.
366
+ * @returns The normalized variation metadata, owning workspace directory, and
367
+ * optional warnings for suspicious but forward-compatible targets.
265
368
  * @throws {Error} When the command is run outside an official workspace, the
266
369
  * target block name is not full `namespace/block` form, or the generated file
267
370
  * already exists.
@@ -270,6 +373,7 @@ export async function runAddCoreVariationCommand({ cwd = process.cwd(), targetBl
270
373
  const workspace = resolveWorkspaceProject(cwd);
271
374
  const resolvedTargetBlockName = assertFullBlockName(targetBlockName, "core-variation target");
272
375
  const variationSlug = assertValidGeneratedSlug("Core variation name", normalizeBlockSlug(variationName), CORE_VARIATION_USAGE);
376
+ const unknownCoreTargetWarning = getUnknownCoreVariationTargetWarning(resolvedTargetBlockName);
273
377
  assertCoreVariationSlugIsNotRegistryIndex(variationSlug);
274
378
  assertCoreVariationDoesNotExist(workspace.projectDir, resolvedTargetBlockName, variationSlug);
275
379
  const bootstrapPath = path.join(workspace.projectDir, `${workspace.packageName.split("/").pop() ?? workspace.packageName}.php`);
@@ -313,6 +417,9 @@ export async function runAddCoreVariationCommand({ cwd = process.cwd(), targetBl
313
417
  targetBlockName: resolvedTargetBlockName,
314
418
  variationFile: path.relative(workspace.projectDir, variationFilePath),
315
419
  variationSlug,
420
+ ...(unknownCoreTargetWarning
421
+ ? { warnings: [unknownCoreTargetWarning] }
422
+ : {}),
316
423
  };
317
424
  }
318
425
  catch (error) {
@@ -51,7 +51,7 @@ function normalizePatternTags(tags) {
51
51
  const rawTags = typeof tags === "string"
52
52
  ? tags.split(",")
53
53
  : Array.isArray(tags)
54
- ? [...tags]
54
+ ? tags.flatMap((tag) => tag.split(","))
55
55
  : [];
56
56
  const normalizedTags = rawTags
57
57
  .map((tag) => normalizeBlockSlug(tag))
@@ -27,7 +27,7 @@ export function formatHelpText() {
27
27
  wp-typia add variation <name> --block <block-slug>
28
28
  wp-typia add style <name> --block <block-slug>
29
29
  wp-typia add transform <name> --from <namespace/block> --to <block-slug|namespace/block-slug>
30
- wp-typia add pattern <name> [--scope <full|section>] [--section-role <role>] [--tags <tag,...>] [--thumbnail-url <url>]
30
+ wp-typia add pattern <name> [--scope <full|section>] [--section-role <role>] [--catalog-title <title>] [--tags <tag,...>|--tag <tag>...] [--thumbnail-url <url>]
31
31
  wp-typia add binding-source <name> [--block <block-slug|namespace/block-slug> --attribute <attribute>] [--from-post-meta|--post-meta <post-meta> [--meta-path <field>]]
32
32
  wp-typia add rest-resource <name> [--namespace <vendor/v1>] [--methods <method[,method...]>]
33
33
  wp-typia add rest-resource <name> --manual [--namespace <vendor/v1>] [--method <GET|POST|PUT|PATCH|DELETE>] [--auth <public|authenticated|public-write-protected>] [--path <route-pattern>|--route-pattern <route-pattern>] [--permission-callback <callback>] [--controller-class <ClassName>] [--controller-extends <BaseClass>] [--query-type <Type>] [--body-type <Type>] [--response-type <Type>] [--secret-field <field>] [--secret-state-field|--secret-has-value-field <field>] [--secret-preserve-on-empty <true|false>]
@@ -56,6 +56,7 @@ Notes:
56
56
  Use \`--template workspace\` as shorthand for \`@wp-typia/create-workspace-template\`, the official empty workspace scaffold behind \`wp-typia add ...\`.
57
57
  Use \`--profile plugin-qa\` with \`--template workspace\` when a plugin needs local wp-env smoke checks, \`.env.example\`, and release zip scripts from day one; omit it for the minimal workspace shell.
58
58
  Interactive add flows let you choose a template when \`--template\` is omitted; non-interactive runs default to \`basic\`.
59
+ Unknown \`core/*\` targets for \`add core-variation\` warn instead of failing so newer WordPress core blocks remain scaffoldable; third-party namespaces are allowed without local discovery.
59
60
  \`add admin-view\` scaffolds an opt-in DataViews-powered WordPress admin screen under \`src/admin-views/\`.
60
61
  Pass \`--source rest-resource:<slug>\` to reuse a list-capable REST resource.
61
62
  Pass \`--source core-data:postType/post\` or \`--source core-data:taxonomy/category\` to bind a WordPress-owned entity collection.
@@ -67,7 +68,7 @@ Notes:
67
68
  \`add variation\` uses an existing workspace block from \`scripts/block-config.ts\`.
68
69
  \`add style\` registers a Block Styles option for an existing generated block.
69
70
  \`add transform\` adds a block-to-block transform into an existing generated block.
70
- \`add pattern\` scaffolds a namespaced PHP pattern shell under \`src/patterns/full/\` or \`src/patterns/sections/\`.
71
+ \`add pattern\` scaffolds a namespaced PHP pattern shell under \`src/patterns/full/\` or \`src/patterns/sections/\`; pass \`--catalog-title "Hero Photo"\` to override the generated catalog title, and pass \`--tags hero,landing\` or repeat \`--tag hero --tag landing\` to set catalog tags.
71
72
  \`add binding-source\` scaffolds shared PHP and editor registration under \`src/bindings/\`; pass \`--block\` and \`--attribute\` together to declare a bindable generated-block attribute. Pass \`--from-post-meta\` or \`--post-meta\` to back the source from a typed post-meta contract and \`--meta-path\` to choose its default top-level field.
72
73
  \`add rest-resource\` scaffolds plugin-level TypeScript REST contracts under \`src/rest/\` and PHP route glue under \`inc/rest/\`.
73
74
  \`add rest-resource --manual\` tracks an external/provider REST route with typed schemas, OpenAPI, clients, and drift checks without generating PHP route/controller files while still allowing route-owner metadata such as permission callbacks and controller classes. Settings contracts can add \`--secret-field\` plus \`--secret-preserve-on-empty\` to model write-only credentials and preserve blank submissions.
@@ -4,11 +4,8 @@
4
4
  * File-backed runtime JSON readers should use `safeJsonParse`,
5
5
  * `readJsonFileSync`, or `readJsonFile` so malformed JSON reports the file path
6
6
  * and operation context. Raw `JSON.parse` remains intentional for trusted
7
- * in-memory clones, subprocess output, test fixtures, generated workspace
8
- * script templates that embed their own path-aware parse handling,
9
- * package-version manifest cache probes with colocated path-aware wrappers,
10
- * and cache/discovery probes that immediately catch malformed documents to
11
- * continue with fallback behavior.
7
+ * in-memory clones, subprocess output, test fixtures, and generated workspace
8
+ * script templates that embed their own path-aware parse handling.
12
9
  *
13
10
  * This module keeps `cloneJsonValue` local instead of re-exporting the
14
11
  * block-runtime helper so Bunli CLI bundles that only need project-tools JSON
@@ -6,11 +6,8 @@ import { promises as fsp } from "node:fs";
6
6
  * File-backed runtime JSON readers should use `safeJsonParse`,
7
7
  * `readJsonFileSync`, or `readJsonFile` so malformed JSON reports the file path
8
8
  * and operation context. Raw `JSON.parse` remains intentional for trusted
9
- * in-memory clones, subprocess output, test fixtures, generated workspace
10
- * script templates that embed their own path-aware parse handling,
11
- * package-version manifest cache probes with colocated path-aware wrappers,
12
- * and cache/discovery probes that immediately catch malformed documents to
13
- * continue with fallback behavior.
9
+ * in-memory clones, subprocess output, test fixtures, and generated workspace
10
+ * script templates that embed their own path-aware parse handling.
14
11
  *
15
12
  * This module keeps `cloneJsonValue` local instead of re-exporting the
16
13
  * block-runtime helper so Bunli CLI bundles that only need project-tools JSON
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import path from 'node:path';
4
4
  import { getOptionalNodeErrorCode } from './fs-async.js';
5
+ import { safeJsonParse } from './json-utils.js';
5
6
  import { PROJECT_TOOLS_PACKAGE_ROOT } from './template-registry.js';
6
7
  const require = createRequire(import.meta.url);
7
8
  const DEFAULT_VERSION_RANGE = '^0.0.0';
@@ -93,12 +94,10 @@ function readPackageManifest(location) {
93
94
  if (!location.packageJsonPath || location.source === null) {
94
95
  return null;
95
96
  }
96
- try {
97
- return JSON.parse(location.source);
98
- }
99
- catch (error) {
100
- throw new Error(`Failed to parse package version manifest at ${location.packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`);
101
- }
97
+ return safeJsonParse(location.source, {
98
+ context: 'package version manifest',
99
+ filePath: location.packageJsonPath,
100
+ });
102
101
  }
103
102
  function tryReadPackageManifest(location) {
104
103
  if (!location) {
@@ -0,0 +1,101 @@
1
+ import { type ParsedBlockPatternBlock } from "@wp-typia/block-runtime/metadata-core";
2
+ import type { PatternCatalogDiagnostic, PatternCatalogEntry } from "./pattern-catalog.js";
3
+ /**
4
+ * Validates section role slugs used by pattern manifests and content markers.
5
+ */
6
+ export declare const PATTERN_SECTION_ROLE_PATTERN: RegExp;
7
+ /**
8
+ * Convention used to discover section role markers in serialized pattern
9
+ * content. Defaults target `core/group` wrappers with a `section` base class,
10
+ * `section--{role}` role class tokens, and `metadata.sectionRole` attributes.
11
+ */
12
+ export type PatternCatalogSectionRoleConvention = {
13
+ /**
14
+ * Serialized block name used as the section wrapper. Defaults to
15
+ * `core/group`.
16
+ */
17
+ wrapperBlockName?: string;
18
+ /**
19
+ * Optional class that marks a wrapper block as section-like even when the
20
+ * role marker is missing. Defaults to `section`.
21
+ */
22
+ baseClassName?: string;
23
+ /**
24
+ * Class token pattern where exactly one `{role}` placeholder is replaced by
25
+ * the section role slug. Defaults to `section--{role}`.
26
+ */
27
+ roleClassNamePattern?: string;
28
+ /**
29
+ * Dot-separated block attribute paths that can carry role slugs. Defaults to
30
+ * `metadata.sectionRole`.
31
+ */
32
+ roleAttributePaths?: readonly string[];
33
+ /**
34
+ * Warn when a full pattern repeats the same section role marker. Defaults to
35
+ * `false`.
36
+ */
37
+ requireUniqueFullPatternRoles?: boolean;
38
+ };
39
+ /**
40
+ * Section wrapper match extracted from a parsed WordPress block tree.
41
+ */
42
+ export type PatternCatalogSectionRoleMatch = {
43
+ blockName: string;
44
+ blockPath: string;
45
+ roles: readonly string[];
46
+ };
47
+ /**
48
+ * Fully resolved section-role marker convention with its compiled class matcher.
49
+ */
50
+ export type NormalizedPatternCatalogSectionRoleConvention = Required<PatternCatalogSectionRoleConvention> & {
51
+ /**
52
+ * Compiled matcher for role class tokens produced from `roleClassNamePattern`.
53
+ */
54
+ roleClassNamePatternRegExp: RegExp;
55
+ };
56
+ /**
57
+ * Resolve a section-role convention and compile its class-token matcher.
58
+ *
59
+ * @param convention Optional marker convention override.
60
+ * @returns A fully populated convention ready for repeated block traversal.
61
+ * @throws When `roleClassNamePattern` does not contain exactly one `{role}` placeholder.
62
+ */
63
+ export declare function normalizePatternCatalogSectionRoleConvention(convention?: PatternCatalogSectionRoleConvention): NormalizedPatternCatalogSectionRoleConvention;
64
+ /**
65
+ * Extract section role slugs from serialized block attributes using the
66
+ * configured class and metadata marker convention.
67
+ *
68
+ * @param attributes Parsed block attributes from serialized pattern content.
69
+ * @param convention Optional marker convention override.
70
+ * @returns Unique role marker values in discovery order.
71
+ */
72
+ export declare function extractPatternSectionRolesFromAttributes(attributes: Record<string, unknown>, convention?: PatternCatalogSectionRoleConvention): string[];
73
+ /**
74
+ * Find section wrapper blocks and their role markers in parsed pattern content.
75
+ *
76
+ * @param blocks Parsed block tree returned by `validateBlockPatternContentNesting`.
77
+ * @param convention Optional marker convention override.
78
+ * @returns Section wrapper matches with serialized block paths.
79
+ */
80
+ export declare function extractPatternSectionRoleMatches(blocks: readonly ParsedBlockPatternBlock[], convention?: PatternCatalogSectionRoleConvention): PatternCatalogSectionRoleMatch[];
81
+ /**
82
+ * Build the known valid section-role set from pattern catalog entries.
83
+ *
84
+ * @param patterns Catalog entries that may declare a `sectionRole`.
85
+ * @returns Valid section role slugs declared by the catalog.
86
+ */
87
+ export declare function collectKnownPatternSectionRoles(patterns: readonly Pick<PatternCatalogEntry, "sectionRole">[]): ReadonlySet<string>;
88
+ /**
89
+ * Validate serialized pattern content against the catalog section-role rules.
90
+ *
91
+ * @param options Pattern content, marker convention, known roles, and diagnostic context.
92
+ * @returns Diagnostics for invalid, missing, mismatched, unknown, or duplicate section-role markers.
93
+ */
94
+ export declare function validatePatternContentSectionRoles({ content, contentFile, convention, knownSectionRoles, label, pattern, }: {
95
+ content: string;
96
+ contentFile: string;
97
+ convention: NormalizedPatternCatalogSectionRoleConvention;
98
+ knownSectionRoles: ReadonlySet<string>;
99
+ label: string;
100
+ pattern: PatternCatalogEntry;
101
+ }): PatternCatalogDiagnostic[];
@@ -0,0 +1,276 @@
1
+ import { validateBlockPatternContentNesting, } from "@wp-typia/block-runtime/metadata-core";
2
+ /**
3
+ * Validates section role slugs used by pattern manifests and content markers.
4
+ */
5
+ export const PATTERN_SECTION_ROLE_PATTERN = /^[a-z][a-z0-9-]*$/u;
6
+ const DEFAULT_SECTION_ROLE_CONVENTION = {
7
+ baseClassName: "section",
8
+ requireUniqueFullPatternRoles: false,
9
+ roleAttributePaths: ["metadata.sectionRole"],
10
+ roleClassNamePattern: "section--{role}",
11
+ wrapperBlockName: "core/group",
12
+ };
13
+ function createPatternCatalogDiagnostic(diagnostic) {
14
+ return diagnostic;
15
+ }
16
+ function normalizeSectionRoleConventionInput(convention = {}) {
17
+ return {
18
+ baseClassName: convention.baseClassName ??
19
+ DEFAULT_SECTION_ROLE_CONVENTION.baseClassName,
20
+ requireUniqueFullPatternRoles: convention.requireUniqueFullPatternRoles ??
21
+ DEFAULT_SECTION_ROLE_CONVENTION.requireUniqueFullPatternRoles,
22
+ roleAttributePaths: convention.roleAttributePaths ??
23
+ DEFAULT_SECTION_ROLE_CONVENTION.roleAttributePaths,
24
+ roleClassNamePattern: convention.roleClassNamePattern ??
25
+ DEFAULT_SECTION_ROLE_CONVENTION.roleClassNamePattern,
26
+ wrapperBlockName: convention.wrapperBlockName ??
27
+ DEFAULT_SECTION_ROLE_CONVENTION.wrapperBlockName,
28
+ };
29
+ }
30
+ function escapeRegExp(value) {
31
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
32
+ }
33
+ function createRoleClassNamePattern(pattern) {
34
+ const parts = pattern.split("{role}");
35
+ if (parts.length !== 2) {
36
+ throw new Error(`roleClassNamePattern must contain exactly one "{role}" placeholder.`);
37
+ }
38
+ return new RegExp(`^${escapeRegExp(parts[0] ?? "")}(?<role>\\S*)${escapeRegExp(parts[1] ?? "")}$`, "u");
39
+ }
40
+ /**
41
+ * Resolve a section-role convention and compile its class-token matcher.
42
+ *
43
+ * @param convention Optional marker convention override.
44
+ * @returns A fully populated convention ready for repeated block traversal.
45
+ * @throws When `roleClassNamePattern` does not contain exactly one `{role}` placeholder.
46
+ */
47
+ export function normalizePatternCatalogSectionRoleConvention(convention = {}) {
48
+ const normalized = normalizeSectionRoleConventionInput(convention);
49
+ return {
50
+ ...normalized,
51
+ roleClassNamePatternRegExp: createRoleClassNamePattern(normalized.roleClassNamePattern),
52
+ };
53
+ }
54
+ function getClassNameTokens(attributes) {
55
+ const className = attributes.className;
56
+ if (typeof className !== "string") {
57
+ return [];
58
+ }
59
+ return className.split(/\s+/u).filter((token) => token.length > 0);
60
+ }
61
+ function getAttributePathValue(attributes, pathName) {
62
+ return pathName.split(".").reduce((current, segment) => {
63
+ if (current !== null &&
64
+ typeof current === "object" &&
65
+ !Array.isArray(current) &&
66
+ Object.prototype.hasOwnProperty.call(current, segment)) {
67
+ return current[segment];
68
+ }
69
+ return undefined;
70
+ }, attributes);
71
+ }
72
+ function collectStringValues(value) {
73
+ if (typeof value === "string") {
74
+ return [value];
75
+ }
76
+ if (Array.isArray(value)) {
77
+ return value.filter((item) => typeof item === "string");
78
+ }
79
+ return [];
80
+ }
81
+ function uniqueValues(values) {
82
+ return [...new Set(values)];
83
+ }
84
+ function formatRoleList(roles) {
85
+ if (roles.length === 0) {
86
+ return "none";
87
+ }
88
+ return roles.map((role) => `"${role}"`).join(", ");
89
+ }
90
+ function formatBlockPaths(paths) {
91
+ return paths.map((blockPath) => `at ${blockPath}`).join(", ");
92
+ }
93
+ function describeRoleMarkerConvention(convention) {
94
+ return `${convention.wrapperBlockName} wrappers with class "${convention.roleClassNamePattern}" or attributes ${convention.roleAttributePaths.join(", ")}`;
95
+ }
96
+ function isSectionWrapperCandidate(block, roles, convention) {
97
+ if (block.blockName !== convention.wrapperBlockName) {
98
+ return false;
99
+ }
100
+ if (roles.length > 0) {
101
+ return true;
102
+ }
103
+ return getClassNameTokens(block.attributes).includes(convention.baseClassName);
104
+ }
105
+ function collectSectionRoleMatches(blocks, convention, pathSegments = []) {
106
+ return blocks.flatMap((block, index) => {
107
+ const blockPathSegments = [
108
+ ...pathSegments,
109
+ `${block.blockName}[${index}]`,
110
+ ];
111
+ const blockPath = blockPathSegments.join(" > ");
112
+ const roles = extractPatternSectionRolesFromNormalizedAttributes(block.attributes, convention);
113
+ const matches = isSectionWrapperCandidate(block, roles, convention)
114
+ ? [
115
+ {
116
+ blockName: block.blockName,
117
+ blockPath,
118
+ roles,
119
+ },
120
+ ]
121
+ : [];
122
+ return [
123
+ ...matches,
124
+ ...collectSectionRoleMatches(block.innerBlocks, convention, blockPathSegments),
125
+ ];
126
+ });
127
+ }
128
+ function unescapeSerializedBlockCommentJsonQuotes(content) {
129
+ return content.replace(/<!--([\s\S]*?)-->/gu, (comment, body) => {
130
+ const source = body.trim();
131
+ if (!source.startsWith("wp:") && !source.startsWith("/wp:")) {
132
+ return comment;
133
+ }
134
+ return `<!--${body.replace(/\\"/gu, '"')}-->`;
135
+ });
136
+ }
137
+ /**
138
+ * Extract section role slugs from serialized block attributes using the
139
+ * configured class and metadata marker convention.
140
+ *
141
+ * @param attributes Parsed block attributes from serialized pattern content.
142
+ * @param convention Optional marker convention override.
143
+ * @returns Unique role marker values in discovery order.
144
+ */
145
+ export function extractPatternSectionRolesFromAttributes(attributes, convention = {}) {
146
+ const normalized = normalizePatternCatalogSectionRoleConvention(convention);
147
+ return extractPatternSectionRolesFromNormalizedAttributes(attributes, normalized);
148
+ }
149
+ function extractPatternSectionRolesFromNormalizedAttributes(attributes, normalized) {
150
+ const classRoles = getClassNameTokens(attributes)
151
+ .map((token) => normalized.roleClassNamePatternRegExp.exec(token)?.groups?.role)
152
+ .filter((role) => typeof role === "string");
153
+ const attributeRoles = normalized.roleAttributePaths.flatMap((pathName) => collectStringValues(getAttributePathValue(attributes, pathName)));
154
+ return uniqueValues([...classRoles, ...attributeRoles]);
155
+ }
156
+ /**
157
+ * Find section wrapper blocks and their role markers in parsed pattern content.
158
+ *
159
+ * @param blocks Parsed block tree returned by `validateBlockPatternContentNesting`.
160
+ * @param convention Optional marker convention override.
161
+ * @returns Section wrapper matches with serialized block paths.
162
+ */
163
+ export function extractPatternSectionRoleMatches(blocks, convention = {}) {
164
+ return collectSectionRoleMatches(blocks, normalizePatternCatalogSectionRoleConvention(convention));
165
+ }
166
+ /**
167
+ * Build the known valid section-role set from pattern catalog entries.
168
+ *
169
+ * @param patterns Catalog entries that may declare a `sectionRole`.
170
+ * @returns Valid section role slugs declared by the catalog.
171
+ */
172
+ export function collectKnownPatternSectionRoles(patterns) {
173
+ return new Set(patterns
174
+ .map((pattern) => pattern.sectionRole)
175
+ .filter((sectionRole) => typeof sectionRole === "string" &&
176
+ PATTERN_SECTION_ROLE_PATTERN.test(sectionRole)));
177
+ }
178
+ /**
179
+ * Validate serialized pattern content against the catalog section-role rules.
180
+ *
181
+ * @param options Pattern content, marker convention, known roles, and diagnostic context.
182
+ * @returns Diagnostics for invalid, missing, mismatched, unknown, or duplicate section-role markers.
183
+ */
184
+ export function validatePatternContentSectionRoles({ content, contentFile, convention, knownSectionRoles, label, pattern, }) {
185
+ const diagnostics = [];
186
+ const parsed = validateBlockPatternContentNesting(content, {
187
+ allowExternalBlockNames: true,
188
+ nesting: {},
189
+ patternFile: contentFile,
190
+ });
191
+ let matches = collectSectionRoleMatches(parsed.blocks, convention);
192
+ const unescapedContent = unescapeSerializedBlockCommentJsonQuotes(content);
193
+ if (unescapedContent !== content) {
194
+ const unescapedParsed = validateBlockPatternContentNesting(unescapedContent, {
195
+ allowExternalBlockNames: true,
196
+ nesting: {},
197
+ patternFile: contentFile,
198
+ });
199
+ const unescapedMatches = collectSectionRoleMatches(unescapedParsed.blocks, convention);
200
+ if (unescapedMatches.some((match) => match.roles.length > 0) ||
201
+ matches.length === 0) {
202
+ matches = unescapedMatches;
203
+ }
204
+ }
205
+ const locatedRoles = matches.flatMap((match) => match.roles.map((role) => ({
206
+ blockPath: match.blockPath,
207
+ role,
208
+ })));
209
+ const invalidRoles = locatedRoles.filter(({ role }) => !PATTERN_SECTION_ROLE_PATTERN.test(role));
210
+ for (const invalidRole of uniqueValues(invalidRoles.map(({ role }) => role))) {
211
+ const paths = invalidRoles
212
+ .filter(({ role }) => role === invalidRole)
213
+ .map(({ blockPath }) => blockPath);
214
+ diagnostics.push(createPatternCatalogDiagnostic({
215
+ code: "invalid-pattern-section-role-marker",
216
+ message: `${label}: section role marker "${invalidRole}" in ${contentFile} must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens (${formatBlockPaths(paths)}).`,
217
+ patternSlug: pattern.slug,
218
+ severity: "error",
219
+ }));
220
+ }
221
+ const validLocatedRoles = locatedRoles.filter(({ role }) => PATTERN_SECTION_ROLE_PATTERN.test(role));
222
+ const validRoles = validLocatedRoles.map(({ role }) => role);
223
+ const uniqueValidRoles = uniqueValues(validRoles);
224
+ const unknownRoles = uniqueValidRoles.filter((role) => !knownSectionRoles.has(role));
225
+ for (const unknownRole of unknownRoles) {
226
+ const paths = validLocatedRoles
227
+ .filter(({ role }) => role === unknownRole)
228
+ .map(({ blockPath }) => blockPath);
229
+ diagnostics.push(createPatternCatalogDiagnostic({
230
+ code: "unknown-pattern-section-role-marker",
231
+ message: `${label}: section role marker "${unknownRole}" in ${contentFile} is not declared by any PATTERNS sectionRole (${formatBlockPaths(paths)}).`,
232
+ patternSlug: pattern.slug,
233
+ severity: "warning",
234
+ }));
235
+ }
236
+ const scope = pattern.scope ?? "full";
237
+ const expectedSectionRole = typeof pattern.sectionRole === "string" &&
238
+ PATTERN_SECTION_ROLE_PATTERN.test(pattern.sectionRole)
239
+ ? pattern.sectionRole
240
+ : undefined;
241
+ if (scope === "section" && expectedSectionRole) {
242
+ if (validRoles.length === 0) {
243
+ diagnostics.push(createPatternCatalogDiagnostic({
244
+ code: "missing-pattern-section-role-marker",
245
+ message: `${label}: section-scoped pattern content in ${contentFile} must include section role marker "${expectedSectionRole}" using ${describeRoleMarkerConvention(convention)}.`,
246
+ patternSlug: pattern.slug,
247
+ severity: "error",
248
+ }));
249
+ }
250
+ else if (!validRoles.includes(expectedSectionRole)) {
251
+ diagnostics.push(createPatternCatalogDiagnostic({
252
+ code: "mismatched-pattern-section-role",
253
+ message: `${label}: manifest sectionRole "${expectedSectionRole}" was not found in ${contentFile}; found ${formatRoleList(uniqueValidRoles)}.`,
254
+ patternSlug: pattern.slug,
255
+ severity: "error",
256
+ }));
257
+ }
258
+ }
259
+ if (scope === "full" && convention.requireUniqueFullPatternRoles) {
260
+ for (const role of uniqueValidRoles) {
261
+ const paths = validLocatedRoles
262
+ .filter((locatedRole) => locatedRole.role === role)
263
+ .map(({ blockPath }) => blockPath);
264
+ if (paths.length <= 1) {
265
+ continue;
266
+ }
267
+ diagnostics.push(createPatternCatalogDiagnostic({
268
+ code: "duplicate-pattern-section-role-marker",
269
+ message: `${label}: full pattern content in ${contentFile} repeats section role marker "${role}" (${formatBlockPaths(paths)}).`,
270
+ patternSlug: pattern.slug,
271
+ severity: "warning",
272
+ }));
273
+ }
274
+ }
275
+ return diagnostics;
276
+ }
@@ -1,4 +1,6 @@
1
- import { type ParsedBlockPatternBlock } from "@wp-typia/block-runtime/metadata-core";
1
+ import type { PatternCatalogSectionRoleConvention } from "./pattern-catalog-section-roles.js";
2
+ export { extractPatternSectionRoleMatches, extractPatternSectionRolesFromAttributes, } from "./pattern-catalog-section-roles.js";
3
+ export type { PatternCatalogSectionRoleConvention, PatternCatalogSectionRoleMatch, } from "./pattern-catalog-section-roles.js";
2
4
  export declare const PATTERN_CATALOG_SCOPE_IDS: readonly ["full", "section"];
3
5
  export type PatternCatalogScope = (typeof PATTERN_CATALOG_SCOPE_IDS)[number];
4
6
  export type PatternCatalogEntry = {
@@ -19,46 +21,6 @@ export type PatternCatalogDiagnostic = {
19
21
  patternSlug?: string;
20
22
  severity: PatternCatalogDiagnosticSeverity;
21
23
  };
22
- /**
23
- * Convention used to discover section role markers in serialized pattern
24
- * content. Defaults target `core/group` wrappers with a `section` base class,
25
- * `section--{role}` role class tokens, and `metadata.sectionRole` attributes.
26
- */
27
- export type PatternCatalogSectionRoleConvention = {
28
- /**
29
- * Serialized block name used as the section wrapper. Defaults to
30
- * `core/group`.
31
- */
32
- wrapperBlockName?: string;
33
- /**
34
- * Optional class that marks a wrapper block as section-like even when the
35
- * role marker is missing. Defaults to `section`.
36
- */
37
- baseClassName?: string;
38
- /**
39
- * Class token pattern where exactly one `{role}` placeholder is replaced by
40
- * the section role slug. Defaults to `section--{role}`.
41
- */
42
- roleClassNamePattern?: string;
43
- /**
44
- * Dot-separated block attribute paths that can carry role slugs. Defaults to
45
- * `metadata.sectionRole`.
46
- */
47
- roleAttributePaths?: readonly string[];
48
- /**
49
- * Warn when a full pattern repeats the same section role marker. Defaults to
50
- * `false`.
51
- */
52
- requireUniqueFullPatternRoles?: boolean;
53
- };
54
- /**
55
- * Section wrapper match extracted from a parsed WordPress block tree.
56
- */
57
- export type PatternCatalogSectionRoleMatch = {
58
- blockName: string;
59
- blockPath: string;
60
- roles: readonly string[];
61
- };
62
24
  /**
63
25
  * Options for validating typed pattern catalog entries and, when `projectDir`
64
26
  * is provided, their serialized pattern content. Set `sectionRoleConvention` to
@@ -74,23 +36,6 @@ export type PatternCatalogValidationResult = {
74
36
  errors: PatternCatalogDiagnostic[];
75
37
  warnings: PatternCatalogDiagnostic[];
76
38
  };
77
- /**
78
- * Extract section role slugs from serialized block attributes using the
79
- * configured class and metadata marker convention.
80
- *
81
- * @param attributes Parsed block attributes from serialized pattern content.
82
- * @param convention Optional marker convention override.
83
- * @returns Unique role marker values in discovery order.
84
- */
85
- export declare function extractPatternSectionRolesFromAttributes(attributes: Record<string, unknown>, convention?: PatternCatalogSectionRoleConvention): string[];
86
- /**
87
- * Find section wrapper blocks and their role markers in parsed pattern content.
88
- *
89
- * @param blocks Parsed block tree returned by `validateBlockPatternContentNesting`.
90
- * @param convention Optional marker convention override.
91
- * @returns Section wrapper matches with serialized block paths.
92
- */
93
- export declare function extractPatternSectionRoleMatches(blocks: readonly ParsedBlockPatternBlock[], convention?: PatternCatalogSectionRoleConvention): PatternCatalogSectionRoleMatch[];
94
39
  /**
95
40
  * Validate pattern thumbnail references with the same URL/path rules used by
96
41
  * catalog diagnostics and `wp-typia add pattern`.
@@ -1,18 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { validateBlockPatternContentNesting, } from "@wp-typia/block-runtime/metadata-core";
3
+ import { PATTERN_SECTION_ROLE_PATTERN, collectKnownPatternSectionRoles, normalizePatternCatalogSectionRoleConvention, validatePatternContentSectionRoles, } from "./pattern-catalog-section-roles.js";
4
+ export { extractPatternSectionRoleMatches, extractPatternSectionRolesFromAttributes, } from "./pattern-catalog-section-roles.js";
4
5
  export const PATTERN_CATALOG_SCOPE_IDS = ["full", "section"];
5
6
  const PATTERN_SLUG_PATTERN = /^[a-z][a-z0-9-]*$/u;
6
- const PATTERN_SECTION_ROLE_PATTERN = PATTERN_SLUG_PATTERN;
7
7
  const PATTERN_TAG_PATTERN = /^[a-z0-9][a-z0-9-]*$/u;
8
8
  const PATTERN_CONTENT_FILE_ROOT = "src/patterns/";
9
- const DEFAULT_SECTION_ROLE_CONVENTION = {
10
- baseClassName: "section",
11
- requireUniqueFullPatternRoles: false,
12
- roleAttributePaths: ["metadata.sectionRole"],
13
- roleClassNamePattern: "section--{role}",
14
- wrapperBlockName: "core/group",
15
- };
16
9
  function createPatternCatalogDiagnostic(diagnostic) {
17
10
  return diagnostic;
18
11
  }
@@ -37,146 +30,6 @@ function isPatternContentFilePath(value) {
37
30
  return ((segments.length === 1 || segments.length === 2) &&
38
31
  segments.every((segment) => segment.length > 0));
39
32
  }
40
- function normalizeSectionRoleConventionInput(convention = {}) {
41
- return {
42
- baseClassName: convention.baseClassName ??
43
- DEFAULT_SECTION_ROLE_CONVENTION.baseClassName,
44
- requireUniqueFullPatternRoles: convention.requireUniqueFullPatternRoles ??
45
- DEFAULT_SECTION_ROLE_CONVENTION.requireUniqueFullPatternRoles,
46
- roleAttributePaths: convention.roleAttributePaths ??
47
- DEFAULT_SECTION_ROLE_CONVENTION.roleAttributePaths,
48
- roleClassNamePattern: convention.roleClassNamePattern ??
49
- DEFAULT_SECTION_ROLE_CONVENTION.roleClassNamePattern,
50
- wrapperBlockName: convention.wrapperBlockName ??
51
- DEFAULT_SECTION_ROLE_CONVENTION.wrapperBlockName,
52
- };
53
- }
54
- function escapeRegExp(value) {
55
- return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
56
- }
57
- function createRoleClassNamePattern(pattern) {
58
- const parts = pattern.split("{role}");
59
- if (parts.length !== 2) {
60
- throw new Error(`roleClassNamePattern must contain exactly one "{role}" placeholder.`);
61
- }
62
- return new RegExp(`^${escapeRegExp(parts[0] ?? "")}(?<role>\\S*)${escapeRegExp(parts[1] ?? "")}$`, "u");
63
- }
64
- function normalizeSectionRoleConvention(convention = {}) {
65
- const normalized = normalizeSectionRoleConventionInput(convention);
66
- return {
67
- ...normalized,
68
- roleClassNamePatternRegExp: createRoleClassNamePattern(normalized.roleClassNamePattern),
69
- };
70
- }
71
- function getClassNameTokens(attributes) {
72
- const className = attributes.className;
73
- if (typeof className !== "string") {
74
- return [];
75
- }
76
- return className.split(/\s+/u).filter((token) => token.length > 0);
77
- }
78
- function getAttributePathValue(attributes, pathName) {
79
- return pathName.split(".").reduce((current, segment) => {
80
- if (current !== null &&
81
- typeof current === "object" &&
82
- !Array.isArray(current) &&
83
- Object.prototype.hasOwnProperty.call(current, segment)) {
84
- return current[segment];
85
- }
86
- return undefined;
87
- }, attributes);
88
- }
89
- function collectStringValues(value) {
90
- if (typeof value === "string") {
91
- return [value];
92
- }
93
- if (Array.isArray(value)) {
94
- return value.filter((item) => typeof item === "string");
95
- }
96
- return [];
97
- }
98
- function uniqueValues(values) {
99
- return [...new Set(values)];
100
- }
101
- function formatRoleList(roles) {
102
- if (roles.length === 0) {
103
- return "none";
104
- }
105
- return roles.map((role) => `"${role}"`).join(", ");
106
- }
107
- function formatBlockPaths(paths) {
108
- return paths.map((blockPath) => `at ${blockPath}`).join(", ");
109
- }
110
- function describeRoleMarkerConvention(convention) {
111
- return `${convention.wrapperBlockName} wrappers with class "${convention.roleClassNamePattern}" or attributes ${convention.roleAttributePaths.join(", ")}`;
112
- }
113
- function isSectionWrapperCandidate(block, roles, convention) {
114
- if (block.blockName !== convention.wrapperBlockName) {
115
- return false;
116
- }
117
- if (roles.length > 0) {
118
- return true;
119
- }
120
- return getClassNameTokens(block.attributes).includes(convention.baseClassName);
121
- }
122
- function collectSectionRoleMatches(blocks, convention, pathSegments = []) {
123
- return blocks.flatMap((block, index) => {
124
- const blockPathSegments = [
125
- ...pathSegments,
126
- `${block.blockName}[${index}]`,
127
- ];
128
- const blockPath = blockPathSegments.join(" > ");
129
- const roles = extractPatternSectionRolesFromAttributes(block.attributes, convention);
130
- const matches = isSectionWrapperCandidate(block, roles, convention)
131
- ? [
132
- {
133
- blockName: block.blockName,
134
- blockPath,
135
- roles,
136
- },
137
- ]
138
- : [];
139
- return [
140
- ...matches,
141
- ...collectSectionRoleMatches(block.innerBlocks, convention, blockPathSegments),
142
- ];
143
- });
144
- }
145
- function unescapeSerializedBlockCommentJsonQuotes(content) {
146
- return content.replace(/<!--([\s\S]*?)-->/gu, (comment, body) => {
147
- const source = body.trim();
148
- if (!source.startsWith("wp:") && !source.startsWith("/wp:")) {
149
- return comment;
150
- }
151
- return `<!--${body.replace(/\\"/gu, '"')}-->`;
152
- });
153
- }
154
- /**
155
- * Extract section role slugs from serialized block attributes using the
156
- * configured class and metadata marker convention.
157
- *
158
- * @param attributes Parsed block attributes from serialized pattern content.
159
- * @param convention Optional marker convention override.
160
- * @returns Unique role marker values in discovery order.
161
- */
162
- export function extractPatternSectionRolesFromAttributes(attributes, convention = {}) {
163
- const normalized = normalizeSectionRoleConvention(convention);
164
- const classRoles = getClassNameTokens(attributes)
165
- .map((token) => normalized.roleClassNamePatternRegExp.exec(token)?.groups?.role)
166
- .filter((role) => typeof role === "string");
167
- const attributeRoles = normalized.roleAttributePaths.flatMap((pathName) => collectStringValues(getAttributePathValue(attributes, pathName)));
168
- return uniqueValues([...classRoles, ...attributeRoles]);
169
- }
170
- /**
171
- * Find section wrapper blocks and their role markers in parsed pattern content.
172
- *
173
- * @param blocks Parsed block tree returned by `validateBlockPatternContentNesting`.
174
- * @param convention Optional marker convention override.
175
- * @returns Section wrapper matches with serialized block paths.
176
- */
177
- export function extractPatternSectionRoleMatches(blocks, convention = {}) {
178
- return collectSectionRoleMatches(blocks, normalizeSectionRoleConvention(convention));
179
- }
180
33
  /**
181
34
  * Validate pattern thumbnail references with the same URL/path rules used by
182
35
  * catalog diagnostics and `wp-typia add pattern`.
@@ -199,105 +52,6 @@ export function isValidPatternThumbnailUrl(value) {
199
52
  export function resolvePatternCatalogContentFile(pattern) {
200
53
  return pattern.contentFile ?? pattern.file;
201
54
  }
202
- function createKnownSectionRoleSet(patterns) {
203
- return new Set(patterns
204
- .map((pattern) => pattern.sectionRole)
205
- .filter((sectionRole) => typeof sectionRole === "string" &&
206
- PATTERN_SECTION_ROLE_PATTERN.test(sectionRole)));
207
- }
208
- function validatePatternContentSectionRoles({ content, contentFile, convention, knownSectionRoles, label, pattern, }) {
209
- const diagnostics = [];
210
- const parsed = validateBlockPatternContentNesting(content, {
211
- allowExternalBlockNames: true,
212
- nesting: {},
213
- patternFile: contentFile,
214
- });
215
- let matches = collectSectionRoleMatches(parsed.blocks, convention);
216
- const unescapedContent = unescapeSerializedBlockCommentJsonQuotes(content);
217
- if (unescapedContent !== content) {
218
- const unescapedParsed = validateBlockPatternContentNesting(unescapedContent, {
219
- allowExternalBlockNames: true,
220
- nesting: {},
221
- patternFile: contentFile,
222
- });
223
- const unescapedMatches = collectSectionRoleMatches(unescapedParsed.blocks, convention);
224
- if (unescapedMatches.some((match) => match.roles.length > 0) ||
225
- matches.length === 0) {
226
- matches = unescapedMatches;
227
- }
228
- }
229
- const locatedRoles = matches.flatMap((match) => match.roles.map((role) => ({
230
- blockPath: match.blockPath,
231
- role,
232
- })));
233
- const invalidRoles = locatedRoles.filter(({ role }) => !PATTERN_SECTION_ROLE_PATTERN.test(role));
234
- for (const invalidRole of uniqueValues(invalidRoles.map(({ role }) => role))) {
235
- const paths = invalidRoles
236
- .filter(({ role }) => role === invalidRole)
237
- .map(({ blockPath }) => blockPath);
238
- diagnostics.push(createPatternCatalogDiagnostic({
239
- code: "invalid-pattern-section-role-marker",
240
- message: `${label}: section role marker "${invalidRole}" in ${contentFile} must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens (${formatBlockPaths(paths)}).`,
241
- patternSlug: pattern.slug,
242
- severity: "error",
243
- }));
244
- }
245
- const validLocatedRoles = locatedRoles.filter(({ role }) => PATTERN_SECTION_ROLE_PATTERN.test(role));
246
- const validRoles = validLocatedRoles.map(({ role }) => role);
247
- const uniqueValidRoles = uniqueValues(validRoles);
248
- const unknownRoles = uniqueValidRoles.filter((role) => !knownSectionRoles.has(role));
249
- for (const unknownRole of unknownRoles) {
250
- const paths = validLocatedRoles
251
- .filter(({ role }) => role === unknownRole)
252
- .map(({ blockPath }) => blockPath);
253
- diagnostics.push(createPatternCatalogDiagnostic({
254
- code: "unknown-pattern-section-role-marker",
255
- message: `${label}: section role marker "${unknownRole}" in ${contentFile} is not declared by any PATTERNS sectionRole (${formatBlockPaths(paths)}).`,
256
- patternSlug: pattern.slug,
257
- severity: "warning",
258
- }));
259
- }
260
- const scope = pattern.scope ?? "full";
261
- const expectedSectionRole = typeof pattern.sectionRole === "string" &&
262
- PATTERN_SECTION_ROLE_PATTERN.test(pattern.sectionRole)
263
- ? pattern.sectionRole
264
- : undefined;
265
- if (scope === "section" && expectedSectionRole) {
266
- if (validRoles.length === 0) {
267
- diagnostics.push(createPatternCatalogDiagnostic({
268
- code: "missing-pattern-section-role-marker",
269
- message: `${label}: section-scoped pattern content in ${contentFile} must include section role marker "${expectedSectionRole}" using ${describeRoleMarkerConvention(convention)}.`,
270
- patternSlug: pattern.slug,
271
- severity: "error",
272
- }));
273
- }
274
- else if (!validRoles.includes(expectedSectionRole)) {
275
- diagnostics.push(createPatternCatalogDiagnostic({
276
- code: "mismatched-pattern-section-role",
277
- message: `${label}: manifest sectionRole "${expectedSectionRole}" was not found in ${contentFile}; found ${formatRoleList(uniqueValidRoles)}.`,
278
- patternSlug: pattern.slug,
279
- severity: "error",
280
- }));
281
- }
282
- }
283
- if (scope === "full" && convention.requireUniqueFullPatternRoles) {
284
- for (const role of uniqueValidRoles) {
285
- const paths = validLocatedRoles
286
- .filter((locatedRole) => locatedRole.role === role)
287
- .map(({ blockPath }) => blockPath);
288
- if (paths.length <= 1) {
289
- continue;
290
- }
291
- diagnostics.push(createPatternCatalogDiagnostic({
292
- code: "duplicate-pattern-section-role-marker",
293
- message: `${label}: full pattern content in ${contentFile} repeats section role marker "${role}" (${formatBlockPaths(paths)}).`,
294
- patternSlug: pattern.slug,
295
- severity: "warning",
296
- }));
297
- }
298
- }
299
- return diagnostics;
300
- }
301
55
  /**
302
56
  * Validate typed pattern catalog metadata declared in `scripts/block-config.ts`.
303
57
  *
@@ -316,7 +70,7 @@ export function validatePatternCatalog(patterns, options = {}) {
316
70
  let sectionRoleConvention = null;
317
71
  if (options.sectionRoleConvention !== false) {
318
72
  try {
319
- sectionRoleConvention = normalizeSectionRoleConvention(options.sectionRoleConvention);
73
+ sectionRoleConvention = normalizePatternCatalogSectionRoleConvention(options.sectionRoleConvention);
320
74
  }
321
75
  catch (error) {
322
76
  diagnostics.push(createPatternCatalogDiagnostic({
@@ -326,7 +80,7 @@ export function validatePatternCatalog(patterns, options = {}) {
326
80
  }));
327
81
  }
328
82
  }
329
- const knownSectionRoles = createKnownSectionRoleSet(patterns);
83
+ const knownSectionRoles = collectKnownPatternSectionRoles(patterns);
330
84
  for (const [index, pattern] of patterns.entries()) {
331
85
  const label = pattern.slug || `PATTERNS[${index}]`;
332
86
  if (!PATTERN_SLUG_PATTERN.test(pattern.slug)) {
@@ -1,3 +1,4 @@
1
+ import { safeJsonParse } from './json-utils.js';
1
2
  /**
2
3
  * Marker file written after a cache entry is fully populated.
3
4
  */
@@ -41,7 +42,9 @@ export function sanitizeExternalTemplateCacheMetadata(metadata) {
41
42
  export function parseExternalTemplateCacheEntryMarker(markerText) {
42
43
  let marker;
43
44
  try {
44
- marker = JSON.parse(markerText);
45
+ marker = safeJsonParse(markerText, {
46
+ context: 'external template cache entry marker',
47
+ });
45
48
  }
46
49
  catch {
47
50
  return null;
@@ -78,7 +81,9 @@ export function isExternalTemplateCacheEntryFreshForTtl(createdAtMs, nowMs, ttlM
78
81
  export function parseExternalTemplateCachePruneMarker(markerText) {
79
82
  let marker;
80
83
  try {
81
- marker = JSON.parse(markerText);
84
+ marker = safeJsonParse(markerText, {
85
+ context: 'external template cache prune marker',
86
+ });
82
87
  }
83
88
  catch {
84
89
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wp-typia/project-tools",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "Project orchestration and programmatic tooling for wp-typia",
5
5
  "packageManager": "bun@1.3.11",
6
6
  "type": "module",
@@ -168,7 +168,7 @@
168
168
  "@wp-typia/api-client": "^0.4.5",
169
169
  "@wp-typia/block-runtime": "^0.7.0",
170
170
  "@wp-typia/rest": "^0.3.13",
171
- "@wp-typia/block-types": "^0.3.0",
171
+ "@wp-typia/block-types": "^0.3.1",
172
172
  "mustache": "^4.2.0",
173
173
  "npm-package-arg": "^13.0.0",
174
174
  "semver": "^7.7.3",