@wp-typia/project-tools 0.24.8 → 0.24.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,15 +4,40 @@ import { parseScaffoldBlockMetadata } from "@wp-typia/block-runtime/blocks";
4
4
  import { WORDPRESS_BLOCK_API_COMPATIBILITY } from "@wp-typia/block-types/blocks/compatibility";
5
5
  import { createDoctorCheck, resolveWorkspaceBootstrapPath, } from "./cli-doctor-workspace-shared.js";
6
6
  import { readJsonFileSync } from "../shared/json-utils.js";
7
+ import { hasPhpFunctionCallWithAssignedStringPrefixArgument, hasPhpFunctionCallWithStringArgumentPrefix, } from "../shared/php-utils.js";
8
+ import { hasExecutablePattern, hasUncommentedPattern, maskTypeScriptComments, maskTypeScriptCommentsAndLiterals, } from "../shared/ts-source-masking.js";
7
9
  import { compareVersionFloors, pickHigherVersionFloor, } from "../shared/version-floor.js";
8
10
  import { DEFAULT_SCAFFOLD_WORDPRESS_TARGET_VERSION } from "../templates/scaffold-compatibility.js";
9
11
  const WORDPRESS_VERSION_CHECK_CODES = {
10
12
  featureMinimum: "wp-typia.workspace.wordpress.feature-minimum",
11
13
  testedTarget: "wp-typia.workspace.wordpress.tested-target",
12
14
  };
15
+ // Both entries read the same `variations` field and are distinguished by value type.
16
+ const BLOCK_VARIATION_BLOCK_JSON_KEYS = {
17
+ registrationBlockJsonMetadata: "variations",
18
+ registrationMetadataFile: "variations",
19
+ };
20
+ const CORE_VARIATION_REGISTRY_IMPORT_PATTERN = /^\s*import\s*(?!type\b)\{[\s\S]*?\}\s*from\s*["']\.\/[^"']+\/[^"']+\/[^"']+["']\s*;?\s*$/mu;
21
+ const REGISTER_BLOCK_VARIATION_CALL_PATTERN = /\bregisterBlockVariation\s*\(/u;
22
+ const REGISTER_WORKSPACE_CORE_VARIATIONS_CALL_PATTERN = /^\s*registerWorkspaceCoreVariations\s*\(\s*\)\s*;?\s*$/mu;
23
+ const REGISTER_BLOCK_BINDINGS_SOURCE_CALL_PATTERN = /\bregisterBlockBindingsSource\s*\(/gu;
24
+ const GET_FIELDS_LIST_PROPERTY_PATTERN = /(?:async\s+)?\bgetFieldsList\s*\([^)]*\)|(?:async\s+)?["']getFieldsList["']\s*\([^)]*\)|\bgetFieldsList\s*:|["']getFieldsList["']\s*:|\bgetFieldsList\s*(?=,|\})/gu;
25
+ const SUPPORTED_ATTRIBUTES_FILTER_PREFIX = "block_bindings_supported_attributes_";
13
26
  function isEnabledMetadataValue(value) {
14
27
  return value !== undefined && value !== false && value !== null;
15
28
  }
29
+ function assertNeverBlockVariationFeature(feature) {
30
+ throw new Error(`Unhandled block variation metadata feature "${String(feature)}".`);
31
+ }
32
+ function isEnabledBlockVariationMetadataFeature(feature, value) {
33
+ if (feature === "registrationBlockJsonMetadata") {
34
+ return Array.isArray(value) && value.length > 0;
35
+ }
36
+ if (feature === "registrationMetadataFile") {
37
+ return typeof value === "string" && value.trim().length > 0;
38
+ }
39
+ return assertNeverBlockVariationFeature(feature);
40
+ }
16
41
  function getNestedMetadataValue(object, key) {
17
42
  if (!object) {
18
43
  return undefined;
@@ -66,6 +91,294 @@ function readExistingTextFile(filePath) {
66
91
  }
67
92
  return fs.readFileSync(filePath, "utf8");
68
93
  }
94
+ function isTypeScriptIdentifierPart(character) {
95
+ return /^[A-Za-z0-9_$]$/u.test(character ?? "");
96
+ }
97
+ function skipTypeScriptWhitespace(source, index) {
98
+ let cursor = index;
99
+ while (/\s/u.test(source[cursor] ?? "")) {
100
+ cursor += 1;
101
+ }
102
+ return cursor;
103
+ }
104
+ function findClosingDelimiter(source, openIndex, openDelimiter, closeDelimiter) {
105
+ let depth = 0;
106
+ for (let cursor = openIndex; cursor < source.length; cursor += 1) {
107
+ const character = source[cursor];
108
+ if (character === openDelimiter) {
109
+ depth += 1;
110
+ continue;
111
+ }
112
+ if (character === closeDelimiter) {
113
+ depth -= 1;
114
+ if (depth === 0) {
115
+ return cursor;
116
+ }
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+ function findTopLevelSatisfiesIndex(source) {
122
+ let braceDepth = 0;
123
+ let bracketDepth = 0;
124
+ let parenthesisDepth = 0;
125
+ for (let cursor = 0; cursor < source.length; cursor += 1) {
126
+ const character = source[cursor];
127
+ if (character === "{") {
128
+ braceDepth += 1;
129
+ continue;
130
+ }
131
+ if (character === "}") {
132
+ braceDepth = Math.max(0, braceDepth - 1);
133
+ continue;
134
+ }
135
+ if (character === "[") {
136
+ bracketDepth += 1;
137
+ continue;
138
+ }
139
+ if (character === "]") {
140
+ bracketDepth = Math.max(0, bracketDepth - 1);
141
+ continue;
142
+ }
143
+ if (character === "(") {
144
+ parenthesisDepth += 1;
145
+ continue;
146
+ }
147
+ if (character === ")") {
148
+ parenthesisDepth = Math.max(0, parenthesisDepth - 1);
149
+ continue;
150
+ }
151
+ if (braceDepth === 0 &&
152
+ bracketDepth === 0 &&
153
+ parenthesisDepth === 0 &&
154
+ source.startsWith("satisfies", cursor) &&
155
+ !isTypeScriptIdentifierPart(source[cursor - 1]) &&
156
+ !isTypeScriptIdentifierPart(source[cursor + "satisfies".length])) {
157
+ return cursor;
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+ function isTopLevelObjectPropertyStart(structureMaskedObjectSource, index) {
163
+ let braceDepth = 0;
164
+ let bracketDepth = 0;
165
+ let parenthesisDepth = 0;
166
+ for (let cursor = 0; cursor < index; cursor += 1) {
167
+ const character = structureMaskedObjectSource[cursor];
168
+ if (character === "{") {
169
+ braceDepth += 1;
170
+ continue;
171
+ }
172
+ if (character === "}") {
173
+ braceDepth = Math.max(0, braceDepth - 1);
174
+ continue;
175
+ }
176
+ if (character === "[") {
177
+ bracketDepth += 1;
178
+ continue;
179
+ }
180
+ if (character === "]") {
181
+ bracketDepth = Math.max(0, bracketDepth - 1);
182
+ continue;
183
+ }
184
+ if (character === "(") {
185
+ parenthesisDepth += 1;
186
+ continue;
187
+ }
188
+ if (character === ")") {
189
+ parenthesisDepth = Math.max(0, parenthesisDepth - 1);
190
+ }
191
+ }
192
+ if (braceDepth !== 1 || bracketDepth !== 0 || parenthesisDepth !== 0) {
193
+ return false;
194
+ }
195
+ let previousCursor = index - 1;
196
+ while (previousCursor >= 0 &&
197
+ /\s/u.test(structureMaskedObjectSource[previousCursor] ?? "")) {
198
+ previousCursor -= 1;
199
+ }
200
+ const previousToken = structureMaskedObjectSource[previousCursor];
201
+ return previousToken === "{" || previousToken === ",";
202
+ }
203
+ function objectLiteralHasGetFieldsListProperty(commentMaskedObjectSource, structureMaskedObjectSource) {
204
+ GET_FIELDS_LIST_PROPERTY_PATTERN.lastIndex = 0;
205
+ let match;
206
+ while ((match = GET_FIELDS_LIST_PROPERTY_PATTERN.exec(commentMaskedObjectSource))) {
207
+ if (isTopLevelObjectPropertyStart(structureMaskedObjectSource, match.index)) {
208
+ GET_FIELDS_LIST_PROPERTY_PATTERN.lastIndex = 0;
209
+ return true;
210
+ }
211
+ }
212
+ GET_FIELDS_LIST_PROPERTY_PATTERN.lastIndex = 0;
213
+ return false;
214
+ }
215
+ function getRuntimeArgumentEnd(maskedCallArgumentsSource) {
216
+ return (findTopLevelSatisfiesIndex(maskedCallArgumentsSource) ??
217
+ maskedCallArgumentsSource.length);
218
+ }
219
+ function getFirstObjectArgumentSpan(structureMaskedRuntimeArgumentsSource, absoluteStart) {
220
+ const objectStartOffset = skipTypeScriptWhitespace(structureMaskedRuntimeArgumentsSource, 0);
221
+ if (structureMaskedRuntimeArgumentsSource[objectStartOffset] !== "{") {
222
+ return undefined;
223
+ }
224
+ const objectEndOffset = findClosingDelimiter(structureMaskedRuntimeArgumentsSource, objectStartOffset, "{", "}");
225
+ if (objectEndOffset === null) {
226
+ return undefined;
227
+ }
228
+ return {
229
+ end: absoluteStart + objectEndOffset + 1,
230
+ start: absoluteStart + objectStartOffset,
231
+ };
232
+ }
233
+ function getSimpleRuntimeArgumentIdentifier(structureMaskedRuntimeArgumentsSource) {
234
+ const trimmed = structureMaskedRuntimeArgumentsSource.trim();
235
+ const match = /^([A-Za-z_$][\w$]*)(?:\s+as\b[\s\S]*)?$/u.exec(trimmed);
236
+ return match?.[1];
237
+ }
238
+ function findObjectInitializerStart(structureMaskedSource, index) {
239
+ let braceDepth = 0;
240
+ let bracketDepth = 0;
241
+ let parenthesisDepth = 0;
242
+ for (let cursor = index; cursor < structureMaskedSource.length; cursor += 1) {
243
+ const character = structureMaskedSource[cursor];
244
+ if (character === "{") {
245
+ braceDepth += 1;
246
+ continue;
247
+ }
248
+ if (character === "}") {
249
+ braceDepth = Math.max(0, braceDepth - 1);
250
+ continue;
251
+ }
252
+ if (character === "[") {
253
+ bracketDepth += 1;
254
+ continue;
255
+ }
256
+ if (character === "]") {
257
+ bracketDepth = Math.max(0, bracketDepth - 1);
258
+ continue;
259
+ }
260
+ if (character === "(") {
261
+ parenthesisDepth += 1;
262
+ continue;
263
+ }
264
+ if (character === ")") {
265
+ parenthesisDepth = Math.max(0, parenthesisDepth - 1);
266
+ continue;
267
+ }
268
+ if (braceDepth !== 0 || bracketDepth !== 0 || parenthesisDepth !== 0) {
269
+ continue;
270
+ }
271
+ if (character === ";") {
272
+ return null;
273
+ }
274
+ if (character !== "=" || structureMaskedSource[cursor + 1] === ">") {
275
+ continue;
276
+ }
277
+ const valueStart = skipTypeScriptWhitespace(structureMaskedSource, cursor + 1);
278
+ return structureMaskedSource[valueStart] === "{" ? valueStart : null;
279
+ }
280
+ return null;
281
+ }
282
+ function isTopLevelTypeScriptPosition(structureMaskedSource, index) {
283
+ let braceDepth = 0;
284
+ let bracketDepth = 0;
285
+ let parenthesisDepth = 0;
286
+ for (let cursor = 0; cursor < index; cursor += 1) {
287
+ const character = structureMaskedSource[cursor];
288
+ if (character === "{") {
289
+ braceDepth += 1;
290
+ continue;
291
+ }
292
+ if (character === "}") {
293
+ braceDepth = Math.max(0, braceDepth - 1);
294
+ continue;
295
+ }
296
+ if (character === "[") {
297
+ bracketDepth += 1;
298
+ continue;
299
+ }
300
+ if (character === "]") {
301
+ bracketDepth = Math.max(0, bracketDepth - 1);
302
+ continue;
303
+ }
304
+ if (character === "(") {
305
+ parenthesisDepth += 1;
306
+ continue;
307
+ }
308
+ if (character === ")") {
309
+ parenthesisDepth = Math.max(0, parenthesisDepth - 1);
310
+ }
311
+ }
312
+ return braceDepth === 0 && bracketDepth === 0 && parenthesisDepth === 0;
313
+ }
314
+ function variableObjectHasGetFieldsListProperty(identifier, commentMaskedSource, structureMaskedSource, beforeIndex) {
315
+ const variablePattern = /\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\b/gu;
316
+ let match;
317
+ let nearestObjectSpan;
318
+ while ((match = variablePattern.exec(structureMaskedSource))) {
319
+ if (match.index >= beforeIndex) {
320
+ break;
321
+ }
322
+ if (match[1] !== identifier) {
323
+ continue;
324
+ }
325
+ if (!isTopLevelTypeScriptPosition(structureMaskedSource, match.index)) {
326
+ continue;
327
+ }
328
+ const objectStart = findObjectInitializerStart(structureMaskedSource, match.index + match[0].length);
329
+ if (objectStart === null || objectStart >= beforeIndex) {
330
+ continue;
331
+ }
332
+ const objectEnd = findClosingDelimiter(structureMaskedSource, objectStart, "{", "}");
333
+ if (objectEnd === null) {
334
+ continue;
335
+ }
336
+ nearestObjectSpan = {
337
+ end: objectEnd + 1,
338
+ start: objectStart,
339
+ };
340
+ }
341
+ return nearestObjectSpan
342
+ ? objectLiteralHasGetFieldsListProperty(commentMaskedSource.slice(nearestObjectSpan.start, nearestObjectSpan.end), structureMaskedSource.slice(nearestObjectSpan.start, nearestObjectSpan.end))
343
+ : false;
344
+ }
345
+ function hasRegisterBlockBindingsSourceGetFieldsList(source) {
346
+ const structureMaskedSource = maskTypeScriptCommentsAndLiterals(source);
347
+ const commentMaskedSource = maskTypeScriptComments(source);
348
+ REGISTER_BLOCK_BINDINGS_SOURCE_CALL_PATTERN.lastIndex = 0;
349
+ let match;
350
+ while ((match =
351
+ REGISTER_BLOCK_BINDINGS_SOURCE_CALL_PATTERN.exec(structureMaskedSource))) {
352
+ const openIndex = structureMaskedSource.indexOf("(", match.index);
353
+ if (openIndex === -1) {
354
+ continue;
355
+ }
356
+ const closeIndex = findClosingDelimiter(structureMaskedSource, openIndex, "(", ")");
357
+ if (closeIndex === null) {
358
+ continue;
359
+ }
360
+ const maskedCallArgumentsSource = structureMaskedSource.slice(openIndex + 1, closeIndex);
361
+ const runtimeArgumentEnd = getRuntimeArgumentEnd(maskedCallArgumentsSource);
362
+ const runtimeArgumentsStart = openIndex + 1;
363
+ const maskedRuntimeArgumentsSource = structureMaskedSource.slice(runtimeArgumentsStart, runtimeArgumentsStart + runtimeArgumentEnd);
364
+ const objectArgumentSpan = getFirstObjectArgumentSpan(maskedRuntimeArgumentsSource, runtimeArgumentsStart);
365
+ if (objectArgumentSpan &&
366
+ objectLiteralHasGetFieldsListProperty(commentMaskedSource.slice(objectArgumentSpan.start, objectArgumentSpan.end), structureMaskedSource.slice(objectArgumentSpan.start, objectArgumentSpan.end))) {
367
+ return true;
368
+ }
369
+ const runtimeArgumentIdentifier = getSimpleRuntimeArgumentIdentifier(maskedRuntimeArgumentsSource);
370
+ if (runtimeArgumentIdentifier &&
371
+ variableObjectHasGetFieldsListProperty(runtimeArgumentIdentifier, commentMaskedSource, structureMaskedSource, match.index)) {
372
+ return true;
373
+ }
374
+ REGISTER_BLOCK_BINDINGS_SOURCE_CALL_PATTERN.lastIndex = closeIndex + 1;
375
+ }
376
+ return false;
377
+ }
378
+ function hasSupportedAttributesFilterRegistration(source) {
379
+ return (hasPhpFunctionCallWithStringArgumentPrefix(source, "add_filter", SUPPORTED_ATTRIBUTES_FILTER_PREFIX) ||
380
+ hasPhpFunctionCallWithAssignedStringPrefixArgument(source, "add_filter", SUPPORTED_ATTRIBUTES_FILTER_PREFIX));
381
+ }
69
382
  function collectBlockMetadataRequirements(workspace, inventory) {
70
383
  const issues = [];
71
384
  const requirements = [];
@@ -101,12 +414,47 @@ function collectBlockMetadataRequirements(workspace, inventory) {
101
414
  pushBlockApiRequirement(requirements, `Block ${block.slug}`, entry);
102
415
  }
103
416
  }
417
+ for (const [feature, entry] of Object.entries(WORDPRESS_BLOCK_API_COMPATIBILITY.blockVariations)) {
418
+ if (!entry.runtime.includes("block-json")) {
419
+ continue;
420
+ }
421
+ const blockJsonFeature = feature;
422
+ const blockJsonKey = BLOCK_VARIATION_BLOCK_JSON_KEYS[blockJsonFeature];
423
+ if (isEnabledBlockVariationMetadataFeature(blockJsonFeature, getNestedMetadataValue(blockJson, blockJsonKey))) {
424
+ pushBlockApiRequirement(requirements, `Block ${block.slug}`, entry);
425
+ }
426
+ }
104
427
  }
105
428
  return {
106
429
  issues,
107
430
  requirements,
108
431
  };
109
432
  }
433
+ function hasGeneratedCoreVariationRegistry(projectDir) {
434
+ // Convention: generated core variation registries are rooted at this entrypoint.
435
+ const registryPath = path.join(projectDir, "src", "editor-plugins", "core-variations", "index.ts");
436
+ const source = readExistingTextFile(registryPath);
437
+ if (!source) {
438
+ return false;
439
+ }
440
+ return (hasUncommentedPattern(source, CORE_VARIATION_REGISTRY_IMPORT_PATTERN) &&
441
+ hasExecutablePattern(source, REGISTER_BLOCK_VARIATION_CALL_PATTERN) &&
442
+ hasExecutablePattern(source, REGISTER_WORKSPACE_CORE_VARIATIONS_CALL_PATTERN));
443
+ }
444
+ function collectVariationRequirements(workspace, inventory) {
445
+ const requirements = [];
446
+ const variationEntries = WORDPRESS_BLOCK_API_COMPATIBILITY.blockVariations;
447
+ for (const variation of inventory.variations) {
448
+ pushBlockApiRequirement(requirements, `Variation ${variation.block}/${variation.slug}`, variationEntries.editorRegistration);
449
+ }
450
+ if (hasGeneratedCoreVariationRegistry(workspace.projectDir)) {
451
+ pushBlockApiRequirement(requirements, "Core variations editor plugin", variationEntries.editorRegistration);
452
+ }
453
+ return {
454
+ issues: [],
455
+ requirements,
456
+ };
457
+ }
110
458
  function collectInventoryCompatibilityRequirements(inventory) {
111
459
  const requirements = [];
112
460
  for (const ability of inventory.abilities) {
@@ -139,11 +487,14 @@ function collectBindingSourceRequirements(workspace, inventory) {
139
487
  pushBlockApiRequirement(requirements, `Binding source ${bindingSource.slug}`, bindingEntries.serverRegistration);
140
488
  pushBlockApiRequirement(requirements, `Binding source ${bindingSource.slug}`, bindingEntries.editorRegistration);
141
489
  const editorFilePath = path.join(workspace.projectDir, bindingSource.editorFile);
142
- if (readExistingTextFile(editorFilePath)?.includes("getFieldsList")) {
490
+ const editorSource = readExistingTextFile(editorFilePath);
491
+ if (editorSource &&
492
+ hasRegisterBlockBindingsSourceGetFieldsList(editorSource)) {
143
493
  pushBlockApiRequirement(requirements, `Binding source ${bindingSource.slug}`, bindingEntries.editorFieldsList);
144
494
  }
145
495
  const serverFilePath = path.join(workspace.projectDir, bindingSource.serverFile);
146
- if (readExistingTextFile(serverFilePath)?.includes("block_bindings_supported_attributes_")) {
496
+ const serverSource = readExistingTextFile(serverFilePath);
497
+ if (serverSource && hasSupportedAttributesFilterRegistration(serverSource)) {
147
498
  pushBlockApiRequirement(requirements, `Binding source ${bindingSource.slug}`, bindingEntries.supportedAttributesFilter);
148
499
  }
149
500
  }
@@ -154,16 +505,19 @@ function collectBindingSourceRequirements(workspace, inventory) {
154
505
  }
155
506
  function collectWordPressVersionRequirements(workspace, inventory) {
156
507
  const blockRequirements = collectBlockMetadataRequirements(workspace, inventory);
508
+ const variationRequirements = collectVariationRequirements(workspace, inventory);
157
509
  const bindingRequirements = collectBindingSourceRequirements(workspace, inventory);
158
510
  const inventoryRequirements = collectInventoryCompatibilityRequirements(inventory);
159
511
  return {
160
512
  issues: [
161
513
  ...blockRequirements.issues,
514
+ ...variationRequirements.issues,
162
515
  ...bindingRequirements.issues,
163
516
  ...inventoryRequirements.issues,
164
517
  ],
165
518
  requirements: [
166
519
  ...blockRequirements.requirements,
520
+ ...variationRequirements.requirements,
167
521
  ...bindingRequirements.requirements,
168
522
  ...inventoryRequirements.requirements,
169
523
  ],
@@ -20,6 +20,32 @@ export declare function hasPhpFunctionDefinition(source: string, functionName: s
20
20
  * @returns Whether `source` contains a code-mode call to `functionName`.
21
21
  */
22
22
  export declare function hasPhpFunctionCall(source: string, functionName: string): boolean;
23
+ /**
24
+ * Detect a PHP function call whose first argument is a variable previously
25
+ * assigned from a literal string prefix in executable PHP code.
26
+ */
27
+ export declare function hasPhpFunctionCallWithAssignedStringPrefixArgument(source: string, functionName: string, literalPrefix: string): boolean;
28
+ /**
29
+ * Detect a PHP function call whose first argument is a literal string value.
30
+ *
31
+ * This uses the same code-mode scanner as {@link hasPhpFunctionCall}, so
32
+ * comments, quoted strings, heredoc, and nowdoc content cannot create matches.
33
+ */
34
+ export declare function hasPhpFunctionCallWithStringArgument(source: string, functionName: string, literalArgument: string): boolean;
35
+ /**
36
+ * Detect a PHP function call whose first argument starts with a literal string prefix.
37
+ *
38
+ * Concatenated expressions are accepted here so dynamic WordPress hooks such as
39
+ * `'block_bindings_supported_attributes_' . $block_type` remain detectable.
40
+ */
41
+ export declare function hasPhpFunctionCallWithStringArgumentPrefix(source: string, functionName: string, literalPrefix: string): boolean;
42
+ /**
43
+ * Detect a code-mode PHP string literal that starts with a literal prefix.
44
+ *
45
+ * This is useful for dynamic hook names stored in variables before a later
46
+ * global function call consumes the variable.
47
+ */
48
+ export declare function hasPhpCodeStringLiteralPrefix(source: string, literalPrefix: string): boolean;
23
49
  /**
24
50
  * Locate a PHP function body without counting braces in non-code regions.
25
51
  *
@@ -132,6 +132,15 @@ function matchesPhpFunctionCallAt(source, index, functionName) {
132
132
  if (isPhpIdentifierPart(source[index - 1])) {
133
133
  return false;
134
134
  }
135
+ let previousCursor = index - 1;
136
+ while (previousCursor >= 0 && /\s/u.test(source[previousCursor] ?? "")) {
137
+ previousCursor -= 1;
138
+ }
139
+ const previousToken = source[previousCursor];
140
+ if ((previousToken === ">" && source[previousCursor - 1] === "-") ||
141
+ (previousToken === ":" && source[previousCursor - 1] === ":")) {
142
+ return false;
143
+ }
135
144
  const cursor = index + functionName.length;
136
145
  if (isPhpIdentifierPart(source[cursor])) {
137
146
  return false;
@@ -139,6 +148,60 @@ function matchesPhpFunctionCallAt(source, index, functionName) {
139
148
  const callStart = skipPhpCallTrivia(source, cursor);
140
149
  return callStart !== null && source[callStart] === "(";
141
150
  }
151
+ function parsePhpQuotedStringLiteralAt(source, index) {
152
+ const quote = source[index];
153
+ if (quote !== "'" && quote !== '"') {
154
+ return null;
155
+ }
156
+ let cursor = index + 1;
157
+ let value = "";
158
+ while (cursor < source.length) {
159
+ const character = source[cursor];
160
+ if (character === "\\") {
161
+ const escapedCharacter = source[cursor + 1];
162
+ if (escapedCharacter === undefined) {
163
+ return null;
164
+ }
165
+ value += escapedCharacter;
166
+ cursor += 2;
167
+ continue;
168
+ }
169
+ if (character === quote) {
170
+ return {
171
+ end: cursor + 1,
172
+ value,
173
+ };
174
+ }
175
+ value += character;
176
+ cursor += 1;
177
+ }
178
+ return null;
179
+ }
180
+ function parsePhpVariableNameAt(source, index) {
181
+ if (source[index] !== "$") {
182
+ return null;
183
+ }
184
+ const nameStart = index + 1;
185
+ if (!isPhpIdentifierStart(source[nameStart])) {
186
+ return null;
187
+ }
188
+ let cursor = nameStart + 1;
189
+ while (isPhpIdentifierPart(source[cursor])) {
190
+ cursor += 1;
191
+ }
192
+ return {
193
+ end: cursor,
194
+ name: source.slice(nameStart, cursor),
195
+ };
196
+ }
197
+ function getPhpFunctionCallFirstArgumentStart(source, index, functionName) {
198
+ const cursor = index + functionName.length;
199
+ const callStart = skipPhpCallTrivia(source, cursor);
200
+ if (callStart === null || source[callStart] !== "(") {
201
+ return null;
202
+ }
203
+ return skipPhpCallTrivia(source, callStart + 1);
204
+ }
142
205
  function createPhpScannerState() {
143
206
  return {
144
207
  heredocDelimiter: "",
@@ -309,6 +372,211 @@ export function hasPhpFunctionCall(source, functionName) {
309
372
  }
310
373
  return false;
311
374
  }
375
+ function isPhpAssignmentOperatorAt(source, index) {
376
+ if (index === null || source[index] !== "=") {
377
+ return false;
378
+ }
379
+ const previousToken = source[index - 1];
380
+ const nextToken = source[index + 1];
381
+ return (previousToken !== "=" &&
382
+ previousToken !== "!" &&
383
+ previousToken !== "<" &&
384
+ previousToken !== ">" &&
385
+ previousToken !== "." &&
386
+ nextToken !== "=" &&
387
+ nextToken !== ">");
388
+ }
389
+ function isSupportedPhpAssignedStringSuffix(source, index) {
390
+ const nextTokenIndex = skipPhpCallTrivia(source, index);
391
+ const nextToken = nextTokenIndex === null ? undefined : source[nextTokenIndex];
392
+ return nextToken === "." || nextToken === ";" || nextToken === undefined;
393
+ }
394
+ function getPhpFunctionCallFirstVariableArgumentName(source, index, functionName) {
395
+ const argumentStart = getPhpFunctionCallFirstArgumentStart(source, index, functionName);
396
+ if (argumentStart === null) {
397
+ return undefined;
398
+ }
399
+ const variable = parsePhpVariableNameAt(source, argumentStart);
400
+ if (!variable) {
401
+ return undefined;
402
+ }
403
+ const argumentEnd = skipPhpCallTrivia(source, variable.end);
404
+ const nextToken = argumentEnd === null ? undefined : source[argumentEnd];
405
+ return nextToken === "," || nextToken === ")" || nextToken === undefined
406
+ ? variable.name
407
+ : undefined;
408
+ }
409
+ /**
410
+ * Detect a PHP function call whose first argument is a variable previously
411
+ * assigned from a literal string prefix in executable PHP code.
412
+ */
413
+ export function hasPhpFunctionCallWithAssignedStringPrefixArgument(source, functionName, literalPrefix) {
414
+ const variablesWithPrefix = new Set();
415
+ const scanner = createPhpScannerState();
416
+ let index = 0;
417
+ while (index < source.length) {
418
+ const scan = advancePhpScanner(source, index, scanner);
419
+ if (scan.ambiguous) {
420
+ return false;
421
+ }
422
+ if (!scan.inCode) {
423
+ index = scan.index;
424
+ continue;
425
+ }
426
+ const variable = parsePhpVariableNameAt(source, index);
427
+ if (variable) {
428
+ const assignmentStart = skipPhpCallTrivia(source, variable.end);
429
+ if (assignmentStart !== null &&
430
+ isPhpAssignmentOperatorAt(source, assignmentStart)) {
431
+ const literalStart = skipPhpCallTrivia(source, assignmentStart + 1);
432
+ const literal = literalStart === null
433
+ ? null
434
+ : parsePhpQuotedStringLiteralAt(source, literalStart);
435
+ if (literal &&
436
+ literal.value.startsWith(literalPrefix) &&
437
+ isSupportedPhpAssignedStringSuffix(source, literal.end)) {
438
+ variablesWithPrefix.add(variable.name);
439
+ index = literal.end;
440
+ continue;
441
+ }
442
+ variablesWithPrefix.delete(variable.name);
443
+ }
444
+ }
445
+ if (matchesPhpFunctionCallAt(source, index, functionName)) {
446
+ const variableArgumentName = getPhpFunctionCallFirstVariableArgumentName(source, index, functionName);
447
+ if (variableArgumentName &&
448
+ variablesWithPrefix.has(variableArgumentName)) {
449
+ return true;
450
+ }
451
+ index += functionName.length;
452
+ continue;
453
+ }
454
+ index += 1;
455
+ }
456
+ return false;
457
+ }
458
+ /**
459
+ * Detect a PHP function call whose first argument is a literal string value.
460
+ *
461
+ * This uses the same code-mode scanner as {@link hasPhpFunctionCall}, so
462
+ * comments, quoted strings, heredoc, and nowdoc content cannot create matches.
463
+ */
464
+ export function hasPhpFunctionCallWithStringArgument(source, functionName, literalArgument) {
465
+ return hasPhpFunctionCallWithStringArgumentMatching(source, functionName, (value) => value === literalArgument, { allowConcatenatedPrefix: false });
466
+ }
467
+ /**
468
+ * Detect a PHP function call whose first argument starts with a literal string prefix.
469
+ *
470
+ * Concatenated expressions are accepted here so dynamic WordPress hooks such as
471
+ * `'block_bindings_supported_attributes_' . $block_type` remain detectable.
472
+ */
473
+ export function hasPhpFunctionCallWithStringArgumentPrefix(source, functionName, literalPrefix) {
474
+ return hasPhpFunctionCallWithStringArgumentMatching(source, functionName, (value) => value.startsWith(literalPrefix), { allowConcatenatedPrefix: true });
475
+ }
476
+ /**
477
+ * Detect a code-mode PHP string literal that starts with a literal prefix.
478
+ *
479
+ * This is useful for dynamic hook names stored in variables before a later
480
+ * global function call consumes the variable.
481
+ */
482
+ export function hasPhpCodeStringLiteralPrefix(source, literalPrefix) {
483
+ let index = 0;
484
+ while (index < source.length) {
485
+ if (source.startsWith("<?php", index)) {
486
+ index += 5;
487
+ continue;
488
+ }
489
+ if (source.startsWith("<?", index) || source.startsWith("?>", index)) {
490
+ index += 2;
491
+ continue;
492
+ }
493
+ if (source[index] === "/" && source[index + 1] === "*") {
494
+ const commentEnd = source.indexOf("*/", index + 2);
495
+ if (commentEnd === -1) {
496
+ return false;
497
+ }
498
+ index = commentEnd + 2;
499
+ continue;
500
+ }
501
+ const literal = parsePhpQuotedStringLiteralAt(source, index);
502
+ if (literal) {
503
+ if (literal.value.startsWith(literalPrefix)) {
504
+ return true;
505
+ }
506
+ index = literal.end;
507
+ continue;
508
+ }
509
+ if (source[index] === "/" && source[index + 1] === "/") {
510
+ index = findPhpLineBoundary(source, index + 2).nextStart;
511
+ continue;
512
+ }
513
+ if (source[index] === "#" && source[index + 1] !== "[") {
514
+ index = findPhpLineBoundary(source, index + 1).nextStart;
515
+ continue;
516
+ }
517
+ const heredoc = parsePhpHeredocStart(source, index);
518
+ if (heredoc) {
519
+ let cursor = heredoc.contentStart;
520
+ let closingEnd = null;
521
+ while (cursor < source.length) {
522
+ closingEnd = findPhpHeredocClosingEnd(source, cursor, heredoc.delimiter);
523
+ if (closingEnd !== null) {
524
+ break;
525
+ }
526
+ cursor = findPhpLineBoundary(source, cursor).nextStart;
527
+ }
528
+ if (closingEnd === null) {
529
+ return false;
530
+ }
531
+ index = findPhpLineBoundary(source, closingEnd).nextStart;
532
+ continue;
533
+ }
534
+ index += 1;
535
+ }
536
+ return false;
537
+ }
538
+ function hasPhpFunctionCallWithStringArgumentMatching(source, functionName, matchesArgument, options) {
539
+ const scanner = createPhpScannerState();
540
+ let index = 0;
541
+ while (index < source.length) {
542
+ const scan = advancePhpScanner(source, index, scanner);
543
+ if (scan.ambiguous) {
544
+ return false;
545
+ }
546
+ if (!scan.inCode) {
547
+ index = scan.index;
548
+ continue;
549
+ }
550
+ if (!matchesPhpFunctionCallAt(source, index, functionName)) {
551
+ index += 1;
552
+ continue;
553
+ }
554
+ const argumentStart = getPhpFunctionCallFirstArgumentStart(source, index, functionName);
555
+ if (argumentStart === null) {
556
+ index += functionName.length;
557
+ continue;
558
+ }
559
+ const argument = parsePhpQuotedStringLiteralAt(source, argumentStart);
560
+ if (!argument) {
561
+ index += functionName.length;
562
+ continue;
563
+ }
564
+ const argumentEnd = skipPhpCallTrivia(source, argument.end);
565
+ const nextToken = argumentEnd === null ? undefined : source[argumentEnd];
566
+ const isCompleteLiteralArgument = nextToken === "," || nextToken === ")" || nextToken === undefined;
567
+ const isSupportedPrefixExpression = options.allowConcatenatedPrefix && nextToken === ".";
568
+ if (!isCompleteLiteralArgument &&
569
+ !isSupportedPrefixExpression) {
570
+ index += functionName.length;
571
+ continue;
572
+ }
573
+ if (matchesArgument(argument.value)) {
574
+ return true;
575
+ }
576
+ index += functionName.length;
577
+ }
578
+ return false;
579
+ }
312
580
  /**
313
581
  * Locate a PHP function body without counting braces in non-code regions.
314
582
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wp-typia/project-tools",
3
- "version": "0.24.8",
3
+ "version": "0.24.10",
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.6",
169
169
  "@wp-typia/block-runtime": "^0.7.1",
170
170
  "@wp-typia/rest": "^0.3.14",
171
- "@wp-typia/block-types": "^0.3.4",
171
+ "@wp-typia/block-types": "^0.3.5",
172
172
  "mustache": "^4.2.0",
173
173
  "npm-package-arg": "^13.0.0",
174
174
  "semver": "^7.7.3",