wp-devdocs-mcp 1.1.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.
- package/LICENSE +21 -0
- package/README.md +407 -0
- package/bin/wp-hooks.js +533 -0
- package/package.json +62 -0
- package/src/constants.js +22 -0
- package/src/db/sqlite.js +1012 -0
- package/src/docs/doc-index-manager.js +128 -0
- package/src/docs/parsers/admin-handbook-parser.js +48 -0
- package/src/docs/parsers/base-doc-parser.js +175 -0
- package/src/docs/parsers/block-editor-parser.js +49 -0
- package/src/docs/parsers/general-doc-parser.js +53 -0
- package/src/docs/parsers/plugin-handbook-parser.js +60 -0
- package/src/docs/parsers/rest-api-parser.js +57 -0
- package/src/docs/parsers/wp-cli-parser.js +61 -0
- package/src/indexer/index-manager.js +187 -0
- package/src/indexer/js-parser.js +306 -0
- package/src/indexer/parser-utils.js +158 -0
- package/src/indexer/php-parser.js +205 -0
- package/src/indexer/sources/github-private.js +57 -0
- package/src/indexer/sources/github-public.js +38 -0
- package/src/indexer/sources/index.js +19 -0
- package/src/indexer/sources/local-folder.js +17 -0
- package/src/mcp-entry.js +109 -0
- package/src/presets.js +68 -0
- package/src/server/tools/get-doc.js +79 -0
- package/src/server/tools/get-hook-context.js +67 -0
- package/src/server/tools/list-docs.js +71 -0
- package/src/server/tools/search-block-apis.js +72 -0
- package/src/server/tools/search-docs.js +55 -0
- package/src/server/tools/search-hooks.js +64 -0
- package/src/server/tools/validate-hook.js +66 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import fg from 'fast-glob';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { fetchSource } from './sources/index.js';
|
|
6
|
+
import { parsePhpFile } from './php-parser.js';
|
|
7
|
+
import { parseJsFile } from './js-parser.js';
|
|
8
|
+
import {
|
|
9
|
+
listSources,
|
|
10
|
+
getSource,
|
|
11
|
+
upsertHook,
|
|
12
|
+
markHooksRemoved,
|
|
13
|
+
upsertBlockRegistration,
|
|
14
|
+
upsertApiUsage,
|
|
15
|
+
getIndexedFile,
|
|
16
|
+
upsertIndexedFile,
|
|
17
|
+
updateSourceLastIndexed,
|
|
18
|
+
} from '../db/sqlite.js';
|
|
19
|
+
import { indexDocsSource } from '../docs/doc-index-manager.js';
|
|
20
|
+
|
|
21
|
+
const IGNORE_PATTERNS = [
|
|
22
|
+
'**/node_modules/**',
|
|
23
|
+
'**/vendor/**',
|
|
24
|
+
'**/dist/**',
|
|
25
|
+
'**/build/**',
|
|
26
|
+
'**/.git/**',
|
|
27
|
+
'**/tests/**',
|
|
28
|
+
'**/test/**',
|
|
29
|
+
'**/__tests__/**',
|
|
30
|
+
'**/spec/**',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const PHP_PATTERNS = ['**/*.php'];
|
|
34
|
+
const JS_PATTERNS = ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Index all enabled sources or a specific source.
|
|
38
|
+
* @param {object} opts - { sourceName, force }
|
|
39
|
+
* @returns {object} Stats about the indexing run
|
|
40
|
+
*/
|
|
41
|
+
export async function indexSources(opts = {}) {
|
|
42
|
+
const { sourceName, force = false } = opts;
|
|
43
|
+
|
|
44
|
+
let sources;
|
|
45
|
+
if (sourceName) {
|
|
46
|
+
const source = getSource(sourceName);
|
|
47
|
+
if (!source) throw new Error(`Source not found: ${sourceName}`);
|
|
48
|
+
if (!source.enabled) throw new Error(`Source "${sourceName}" is disabled`);
|
|
49
|
+
sources = [source];
|
|
50
|
+
} else {
|
|
51
|
+
sources = listSources().filter(s => s.enabled);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (sources.length === 0) {
|
|
55
|
+
return { message: 'No enabled sources found. Add a source first.' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const stats = {
|
|
59
|
+
sources_processed: 0,
|
|
60
|
+
files_processed: 0,
|
|
61
|
+
files_skipped: 0,
|
|
62
|
+
hooks_inserted: 0,
|
|
63
|
+
hooks_updated: 0,
|
|
64
|
+
hooks_skipped: 0,
|
|
65
|
+
hooks_removed: 0,
|
|
66
|
+
blocks_indexed: 0,
|
|
67
|
+
apis_indexed: 0,
|
|
68
|
+
docs_inserted: 0,
|
|
69
|
+
docs_updated: 0,
|
|
70
|
+
docs_skipped: 0,
|
|
71
|
+
docs_removed: 0,
|
|
72
|
+
errors: [],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
for (const source of sources) {
|
|
76
|
+
try {
|
|
77
|
+
console.error(`Fetching source: ${source.name} (${source.type})...`);
|
|
78
|
+
const localPath = await fetchSource(source);
|
|
79
|
+
console.error(`Indexing source: ${source.name} from ${localPath} (${source.content_type || 'source'})`);
|
|
80
|
+
|
|
81
|
+
if (source.content_type === 'docs') {
|
|
82
|
+
await indexDocsSource(source, localPath, force, stats);
|
|
83
|
+
} else {
|
|
84
|
+
await indexSource(source, localPath, force, stats);
|
|
85
|
+
}
|
|
86
|
+
stats.sources_processed++;
|
|
87
|
+
updateSourceLastIndexed(source.id);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const msg = `Error processing source "${source.name}": ${err.message}`;
|
|
90
|
+
console.error(msg);
|
|
91
|
+
stats.errors.push(msg);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return stats;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Index a single source — scans files, parses hooks/blocks/APIs, and upserts into the database.
|
|
100
|
+
* @param {object} source - Source row from the database
|
|
101
|
+
* @param {string} localPath - Absolute path to the source on disk
|
|
102
|
+
* @param {boolean} force - Skip mtime/hash caching when true
|
|
103
|
+
* @param {object} stats - Mutable stats object to accumulate counts
|
|
104
|
+
*/
|
|
105
|
+
async function indexSource(source, localPath, force, stats) {
|
|
106
|
+
// Scan for PHP and JS/TS files
|
|
107
|
+
const files = await fg([...PHP_PATTERNS, ...JS_PATTERNS], {
|
|
108
|
+
cwd: localPath,
|
|
109
|
+
ignore: IGNORE_PATTERNS,
|
|
110
|
+
absolute: false,
|
|
111
|
+
onlyFiles: true,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
console.error(`Found ${files.length} files to check in ${source.name}`);
|
|
115
|
+
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
const fullPath = join(localPath, file);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const fileStat = statSync(fullPath);
|
|
121
|
+
const mtimeMs = fileStat.mtimeMs;
|
|
122
|
+
|
|
123
|
+
// Check mtime for incremental skip
|
|
124
|
+
const indexed = !force ? getIndexedFile(source.id, file) : null;
|
|
125
|
+
if (indexed && indexed.mtime_ms === mtimeMs) {
|
|
126
|
+
stats.files_skipped++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
131
|
+
const contentHash = createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
132
|
+
|
|
133
|
+
// Skip if content hash matches (handles moved files / touched timestamps)
|
|
134
|
+
if (indexed && indexed.content_hash === contentHash) {
|
|
135
|
+
upsertIndexedFile(source.id, file, mtimeMs, contentHash);
|
|
136
|
+
stats.files_skipped++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const isPhp = file.endsWith('.php');
|
|
141
|
+
const activeHookIds = [];
|
|
142
|
+
|
|
143
|
+
if (isPhp) {
|
|
144
|
+
const hooks = parsePhpFile(content, file, source.id);
|
|
145
|
+
for (const hook of hooks) {
|
|
146
|
+
const result = upsertHook(hook);
|
|
147
|
+
activeHookIds.push(result.id);
|
|
148
|
+
if (result.action === 'inserted') stats.hooks_inserted++;
|
|
149
|
+
else if (result.action === 'updated') stats.hooks_updated++;
|
|
150
|
+
else stats.hooks_skipped++;
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
const { hooks, blocks, apis } = parseJsFile(content, file, source.id);
|
|
154
|
+
|
|
155
|
+
for (const hook of hooks) {
|
|
156
|
+
const result = upsertHook(hook);
|
|
157
|
+
activeHookIds.push(result.id);
|
|
158
|
+
if (result.action === 'inserted') stats.hooks_inserted++;
|
|
159
|
+
else if (result.action === 'updated') stats.hooks_updated++;
|
|
160
|
+
else stats.hooks_skipped++;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const block of blocks) {
|
|
164
|
+
upsertBlockRegistration(block);
|
|
165
|
+
stats.blocks_indexed++;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const api of apis) {
|
|
169
|
+
upsertApiUsage(api);
|
|
170
|
+
stats.apis_indexed++;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Soft-delete hooks that were in this file but no longer found
|
|
175
|
+
const removed = markHooksRemoved(source.id, file, activeHookIds);
|
|
176
|
+
stats.hooks_removed += removed;
|
|
177
|
+
|
|
178
|
+
// Track this file as indexed
|
|
179
|
+
upsertIndexedFile(source.id, file, mtimeMs, contentHash);
|
|
180
|
+
stats.files_processed++;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const msg = `Error indexing file ${file}: ${err.message}`;
|
|
183
|
+
console.error(msg);
|
|
184
|
+
stats.errors.push(msg);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getLineNumber,
|
|
3
|
+
extractCodeWindow,
|
|
4
|
+
generateContentHash,
|
|
5
|
+
inferDescription,
|
|
6
|
+
extractDocblock,
|
|
7
|
+
findEnclosingFunction,
|
|
8
|
+
} from './parser-utils.js';
|
|
9
|
+
|
|
10
|
+
// JS hook patterns — addAction/addFilter from @wordpress/hooks or wp.hooks
|
|
11
|
+
const JS_HOOK_REGEX = /\b(?:addAction|addFilter|applyFilters|doAction)\s*\(\s*/g;
|
|
12
|
+
|
|
13
|
+
const JS_TYPE_MAP = {
|
|
14
|
+
addAction: 'js_action',
|
|
15
|
+
addFilter: 'js_filter',
|
|
16
|
+
applyFilters: 'js_filter',
|
|
17
|
+
doAction: 'js_action',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Block registration patterns
|
|
21
|
+
const BLOCK_REG_REGEX = /\b(registerBlockType|registerBlockVariation|registerBlockStyle|registerBlockCollection)\s*\(\s*/g;
|
|
22
|
+
|
|
23
|
+
// WP API usage patterns
|
|
24
|
+
const API_USAGE_REGEX = /\bwp\.(blocks|editor|blockEditor|data|element|components|plugins|editPost|editSite|hooks|i18n|richText)\s*\.\s*(\w+)/g;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse a JS/TS file and extract hooks, block registrations, and API usages.
|
|
28
|
+
*/
|
|
29
|
+
export function parseJsFile(content, filePath, sourceId) {
|
|
30
|
+
const hooks = [];
|
|
31
|
+
const blocks = [];
|
|
32
|
+
const apis = [];
|
|
33
|
+
const lines = content.split('\n');
|
|
34
|
+
|
|
35
|
+
// --- JS Hooks ---
|
|
36
|
+
let match;
|
|
37
|
+
JS_HOOK_REGEX.lastIndex = 0;
|
|
38
|
+
while ((match = JS_HOOK_REGEX.exec(content)) !== null) {
|
|
39
|
+
const funcName = match[0].match(/\b(addAction|addFilter|applyFilters|doAction)/)[1];
|
|
40
|
+
const type = JS_TYPE_MAP[funcName];
|
|
41
|
+
const startOffset = match.index + match[0].length;
|
|
42
|
+
|
|
43
|
+
const argsStr = extractJsArguments(content, startOffset);
|
|
44
|
+
if (!argsStr) continue;
|
|
45
|
+
|
|
46
|
+
const args = splitJsArguments(argsStr);
|
|
47
|
+
if (args.length === 0) continue;
|
|
48
|
+
|
|
49
|
+
const rawName = args[0].trim();
|
|
50
|
+
const hookName = cleanJsHookName(rawName);
|
|
51
|
+
if (!hookName) continue;
|
|
52
|
+
|
|
53
|
+
const isDynamic = rawName.includes('`') || rawName.includes('${') || rawName.includes('+');
|
|
54
|
+
|
|
55
|
+
const lineNumber = getLineNumber(content, match.index);
|
|
56
|
+
const lineIndex = lineNumber - 1;
|
|
57
|
+
|
|
58
|
+
const params = args.slice(1).map(p => p.trim()).filter(Boolean);
|
|
59
|
+
const docblock = extractDocblock(lines, lineIndex);
|
|
60
|
+
const { codeBefore, hookLine, codeAfter } = extractCodeWindow(lines, lineIndex);
|
|
61
|
+
const jsFunction = findEnclosingFunction(lines, lineIndex);
|
|
62
|
+
|
|
63
|
+
const hookData = {
|
|
64
|
+
source_id: sourceId,
|
|
65
|
+
file_path: filePath,
|
|
66
|
+
line_number: lineNumber,
|
|
67
|
+
name: hookName,
|
|
68
|
+
type,
|
|
69
|
+
php_function: jsFunction || null,
|
|
70
|
+
params: params.join(', ') || null,
|
|
71
|
+
param_count: params.length,
|
|
72
|
+
docblock: docblock || null,
|
|
73
|
+
inferred_description: null,
|
|
74
|
+
function_context: jsFunction || null,
|
|
75
|
+
class_name: null,
|
|
76
|
+
code_before: codeBefore || null,
|
|
77
|
+
code_after: codeAfter || null,
|
|
78
|
+
hook_line: hookLine || null,
|
|
79
|
+
is_dynamic: isDynamic ? 1 : 0,
|
|
80
|
+
content_hash: null,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
hookData.inferred_description = inferDescription(hookData);
|
|
84
|
+
hookData.content_hash = generateContentHash(hookData);
|
|
85
|
+
hooks.push(hookData);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Block Registrations ---
|
|
89
|
+
BLOCK_REG_REGEX.lastIndex = 0;
|
|
90
|
+
while ((match = BLOCK_REG_REGEX.exec(content)) !== null) {
|
|
91
|
+
const regFunc = match[1];
|
|
92
|
+
const startOffset = match.index + match[0].length;
|
|
93
|
+
|
|
94
|
+
const argsStr = extractJsArguments(content, startOffset);
|
|
95
|
+
if (!argsStr) continue;
|
|
96
|
+
|
|
97
|
+
const args = splitJsArguments(argsStr);
|
|
98
|
+
if (args.length === 0) continue;
|
|
99
|
+
|
|
100
|
+
const blockName = cleanJsHookName(args[0].trim());
|
|
101
|
+
if (!blockName) continue;
|
|
102
|
+
|
|
103
|
+
const lineNumber = getLineNumber(content, match.index);
|
|
104
|
+
const lineIndex = lineNumber - 1;
|
|
105
|
+
const { codeBefore, hookLine, codeAfter } = extractCodeWindow(lines, lineIndex, 4, 20);
|
|
106
|
+
|
|
107
|
+
// Try to extract settings object
|
|
108
|
+
const settingsStr = args.length > 1 ? args.slice(1).join(', ') : '';
|
|
109
|
+
const blockTitle = extractProperty(settingsStr, 'title');
|
|
110
|
+
const blockCategory = extractProperty(settingsStr, 'category');
|
|
111
|
+
|
|
112
|
+
const codeContext = [codeBefore, hookLine, codeAfter].filter(Boolean).join('\n');
|
|
113
|
+
|
|
114
|
+
const blockData = {
|
|
115
|
+
source_id: sourceId,
|
|
116
|
+
file_path: filePath,
|
|
117
|
+
line_number: lineNumber,
|
|
118
|
+
block_name: blockName,
|
|
119
|
+
block_title: blockTitle || null,
|
|
120
|
+
block_category: blockCategory || null,
|
|
121
|
+
block_attributes: null,
|
|
122
|
+
supports: null,
|
|
123
|
+
code_context: codeContext.slice(0, 2000) || null,
|
|
124
|
+
content_hash: null,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
blockData.content_hash = generateContentHash({
|
|
128
|
+
name: blockName,
|
|
129
|
+
type: regFunc,
|
|
130
|
+
params: settingsStr,
|
|
131
|
+
docblock: '',
|
|
132
|
+
hookLine: hookLine,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
blocks.push(blockData);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- API Usages ---
|
|
139
|
+
API_USAGE_REGEX.lastIndex = 0;
|
|
140
|
+
while ((match = API_USAGE_REGEX.exec(content)) !== null) {
|
|
141
|
+
const namespace = match[1];
|
|
142
|
+
const method = match[2];
|
|
143
|
+
const apiCall = `wp.${namespace}.${method}`;
|
|
144
|
+
|
|
145
|
+
const lineNumber = getLineNumber(content, match.index);
|
|
146
|
+
const lineIndex = lineNumber - 1;
|
|
147
|
+
const { codeBefore, hookLine, codeAfter } = extractCodeWindow(lines, lineIndex, 3, 3);
|
|
148
|
+
const codeContext = [codeBefore, hookLine, codeAfter].filter(Boolean).join('\n');
|
|
149
|
+
|
|
150
|
+
const apiData = {
|
|
151
|
+
source_id: sourceId,
|
|
152
|
+
file_path: filePath,
|
|
153
|
+
line_number: lineNumber,
|
|
154
|
+
api_call: apiCall,
|
|
155
|
+
namespace,
|
|
156
|
+
method,
|
|
157
|
+
code_context: codeContext.slice(0, 2000) || null,
|
|
158
|
+
content_hash: null,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
apiData.content_hash = generateContentHash({
|
|
162
|
+
name: apiCall,
|
|
163
|
+
type: 'api_usage',
|
|
164
|
+
params: '',
|
|
165
|
+
docblock: '',
|
|
166
|
+
hookLine: hookLine,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
apis.push(apiData);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { hooks, blocks, apis };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Extract arguments string (handles nested parens, brackets, braces, strings, template literals).
|
|
177
|
+
*/
|
|
178
|
+
function extractJsArguments(content, start) {
|
|
179
|
+
let depth = 1;
|
|
180
|
+
let i = start;
|
|
181
|
+
const max = Math.min(start + 5000, content.length);
|
|
182
|
+
|
|
183
|
+
while (i < max && depth > 0) {
|
|
184
|
+
const ch = content[i];
|
|
185
|
+
if (ch === '(') depth++;
|
|
186
|
+
else if (ch === ')') depth--;
|
|
187
|
+
else if (ch === "'" || ch === '"' || ch === '`') {
|
|
188
|
+
i = skipJsString(content, i);
|
|
189
|
+
} else if (ch === '/' && i + 1 < max && content[i + 1] === '/') {
|
|
190
|
+
// Line comment — skip to end of line
|
|
191
|
+
while (i < max && content[i] !== '\n') i++;
|
|
192
|
+
} else if (ch === '/' && i + 1 < max && content[i + 1] === '*') {
|
|
193
|
+
// Block comment
|
|
194
|
+
i += 2;
|
|
195
|
+
while (i < max && !(content[i] === '*' && content[i + 1] === '/')) i++;
|
|
196
|
+
i++; // skip past /
|
|
197
|
+
}
|
|
198
|
+
if (depth > 0) i++;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (depth !== 0) return null;
|
|
202
|
+
return content.slice(start, i);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function skipJsString(content, i) {
|
|
206
|
+
const quote = content[i];
|
|
207
|
+
i++;
|
|
208
|
+
while (i < content.length) {
|
|
209
|
+
if (content[i] === '\\') {
|
|
210
|
+
i += 2;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (quote === '`' && content[i] === '$' && content[i + 1] === '{') {
|
|
214
|
+
// Template literal expression — skip nested
|
|
215
|
+
i += 2;
|
|
216
|
+
let depth = 1;
|
|
217
|
+
while (i < content.length && depth > 0) {
|
|
218
|
+
if (content[i] === '{') depth++;
|
|
219
|
+
else if (content[i] === '}') depth--;
|
|
220
|
+
if (depth > 0) i++;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (content[i] === quote) return i;
|
|
224
|
+
i++;
|
|
225
|
+
}
|
|
226
|
+
return i;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function splitJsArguments(argsStr) {
|
|
230
|
+
const args = [];
|
|
231
|
+
let current = '';
|
|
232
|
+
let depth = 0;
|
|
233
|
+
let inString = false;
|
|
234
|
+
let stringChar = '';
|
|
235
|
+
|
|
236
|
+
for (let i = 0; i < argsStr.length; i++) {
|
|
237
|
+
const ch = argsStr[i];
|
|
238
|
+
|
|
239
|
+
if (inString) {
|
|
240
|
+
current += ch;
|
|
241
|
+
if (ch === '\\') {
|
|
242
|
+
i++;
|
|
243
|
+
if (i < argsStr.length) current += argsStr[i];
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (ch === stringChar) inString = false;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
251
|
+
inString = true;
|
|
252
|
+
stringChar = ch;
|
|
253
|
+
current += ch;
|
|
254
|
+
} else if (ch === '(' || ch === '[' || ch === '{') {
|
|
255
|
+
depth++;
|
|
256
|
+
current += ch;
|
|
257
|
+
} else if (ch === ')' || ch === ']' || ch === '}') {
|
|
258
|
+
depth--;
|
|
259
|
+
current += ch;
|
|
260
|
+
} else if (ch === ',' && depth === 0) {
|
|
261
|
+
args.push(current);
|
|
262
|
+
current = '';
|
|
263
|
+
} else {
|
|
264
|
+
current += ch;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (current.trim()) args.push(current);
|
|
269
|
+
return args;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function cleanJsHookName(raw) {
|
|
273
|
+
const trimmed = raw.trim();
|
|
274
|
+
|
|
275
|
+
// Simple quoted string
|
|
276
|
+
const simpleMatch = trimmed.match(/^['"`]([^'"`]+)['"`]$/);
|
|
277
|
+
if (simpleMatch) return simpleMatch[1];
|
|
278
|
+
|
|
279
|
+
// Template literal without expressions
|
|
280
|
+
const templateMatch = trimmed.match(/^`([^$`]+)`$/);
|
|
281
|
+
if (templateMatch) return templateMatch[1];
|
|
282
|
+
|
|
283
|
+
// Template literal with expressions
|
|
284
|
+
if (trimmed.startsWith('`')) {
|
|
285
|
+
return trimmed.replace(/`/g, '').replace(/\$\{[^}]+\}/g, '{dynamic}') || null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// String concatenation
|
|
289
|
+
if (trimmed.includes('+')) {
|
|
290
|
+
const parts = trimmed.split(/\s*\+\s*/);
|
|
291
|
+
const cleaned = parts.map(part => {
|
|
292
|
+
const qm = part.trim().match(/^['"`]([^'"`]*)['"`]$/);
|
|
293
|
+
if (qm) return qm[1];
|
|
294
|
+
return '{dynamic}';
|
|
295
|
+
}).join('');
|
|
296
|
+
return cleaned || null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function extractProperty(objStr, prop) {
|
|
303
|
+
const regex = new RegExp(`(?:['"]?${prop}['"]?)\\s*:\\s*['"\`]([^'"\`]+)['"\`]`);
|
|
304
|
+
const match = objStr.match(regex);
|
|
305
|
+
return match ? match[1] : null;
|
|
306
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Get 1-based line number for a character offset in content.
|
|
5
|
+
*/
|
|
6
|
+
export function getLineNumber(content, offset) {
|
|
7
|
+
let line = 1;
|
|
8
|
+
for (let i = 0; i < offset && i < content.length; i++) {
|
|
9
|
+
if (content[i] === '\n') line++;
|
|
10
|
+
}
|
|
11
|
+
return line;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract a code window around a given line.
|
|
16
|
+
* Returns { codeBefore, codeAfter, hookLine }.
|
|
17
|
+
*/
|
|
18
|
+
export function extractCodeWindow(lines, lineIndex, before = 8, after = 4) {
|
|
19
|
+
const start = Math.max(0, lineIndex - before);
|
|
20
|
+
const end = Math.min(lines.length - 1, lineIndex + after);
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
codeBefore: lines.slice(start, lineIndex).join('\n'),
|
|
24
|
+
hookLine: lines[lineIndex] || '',
|
|
25
|
+
codeAfter: lines.slice(lineIndex + 1, end + 1).join('\n'),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate a content hash from the relevant parts of a hook for change detection.
|
|
31
|
+
*/
|
|
32
|
+
export function generateContentHash(data) {
|
|
33
|
+
const hash = createHash('sha256');
|
|
34
|
+
hash.update(JSON.stringify({
|
|
35
|
+
name: data.name,
|
|
36
|
+
type: data.type,
|
|
37
|
+
params: data.params,
|
|
38
|
+
docblock: data.docblock,
|
|
39
|
+
hookLine: data.hookLine,
|
|
40
|
+
}));
|
|
41
|
+
return hash.digest('hex').slice(0, 16);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Infer a human-readable description from hook parts.
|
|
46
|
+
*/
|
|
47
|
+
export function inferDescription(data) {
|
|
48
|
+
const parts = [];
|
|
49
|
+
|
|
50
|
+
const typeLabel = {
|
|
51
|
+
action: 'Action hook',
|
|
52
|
+
filter: 'Filter hook',
|
|
53
|
+
action_ref_array: 'Action hook (ref array)',
|
|
54
|
+
filter_ref_array: 'Filter hook (ref array)',
|
|
55
|
+
js_action: 'JavaScript action hook',
|
|
56
|
+
js_filter: 'JavaScript filter hook',
|
|
57
|
+
}[data.type] || 'Hook';
|
|
58
|
+
|
|
59
|
+
parts.push(typeLabel);
|
|
60
|
+
|
|
61
|
+
if (data.is_dynamic) {
|
|
62
|
+
parts.push('(dynamic name)');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
parts.push(`"${data.name}"`);
|
|
66
|
+
|
|
67
|
+
if (data.php_function) {
|
|
68
|
+
parts.push(`in ${data.php_function}()`);
|
|
69
|
+
}
|
|
70
|
+
if (data.class_name) {
|
|
71
|
+
parts.push(`of class ${data.class_name}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (data.param_count > 0) {
|
|
75
|
+
parts.push(`with ${data.param_count} parameter${data.param_count > 1 ? 's' : ''}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return parts.join(' ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract docblock from lines above a given line index.
|
|
83
|
+
* Looks up to `maxLines` lines above for a closing doc comment.
|
|
84
|
+
*/
|
|
85
|
+
export function extractDocblock(lines, lineIndex, maxLines = 5) {
|
|
86
|
+
const docLines = [];
|
|
87
|
+
let foundEnd = false;
|
|
88
|
+
|
|
89
|
+
for (let i = lineIndex - 1; i >= Math.max(0, lineIndex - maxLines); i--) {
|
|
90
|
+
const line = lines[i].trim();
|
|
91
|
+
|
|
92
|
+
if (!foundEnd) {
|
|
93
|
+
// Look for closing */ or a @-annotated line or * continuation
|
|
94
|
+
if (line.endsWith('*/') || line.startsWith('*') || line.startsWith('/**')) {
|
|
95
|
+
foundEnd = true;
|
|
96
|
+
docLines.unshift(lines[i]);
|
|
97
|
+
} else if (line === '' || line.startsWith('//')) {
|
|
98
|
+
continue;
|
|
99
|
+
} else {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
docLines.unshift(lines[i]);
|
|
104
|
+
if (line.startsWith('/**') || line.startsWith('/*')) {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (docLines.length === 0) return '';
|
|
111
|
+
return docLines.join('\n').trim();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Find the enclosing function/method name for a given line index by scanning upward.
|
|
116
|
+
*/
|
|
117
|
+
export function findEnclosingFunction(lines, lineIndex) {
|
|
118
|
+
// PHP function pattern
|
|
119
|
+
const phpFuncRe = /(?:public|protected|private|static|\s)*function\s+(\w+)\s*\(/;
|
|
120
|
+
// JS function patterns
|
|
121
|
+
const jsFuncRe = /(?:(?:async\s+)?function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:function|\(|=>))/;
|
|
122
|
+
|
|
123
|
+
let braceDepth = 0;
|
|
124
|
+
for (let i = lineIndex; i >= 0; i--) {
|
|
125
|
+
const line = lines[i];
|
|
126
|
+
|
|
127
|
+
// Count braces to track nesting
|
|
128
|
+
for (let c = line.length - 1; c >= 0; c--) {
|
|
129
|
+
if (line[c] === '}') braceDepth++;
|
|
130
|
+
if (line[c] === '{') braceDepth--;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const phpMatch = line.match(phpFuncRe);
|
|
134
|
+
if (phpMatch && braceDepth <= 0) return phpMatch[1];
|
|
135
|
+
|
|
136
|
+
const jsMatch = line.match(jsFuncRe);
|
|
137
|
+
if (jsMatch && braceDepth <= 0) return jsMatch[1] || jsMatch[2];
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Find the enclosing class name for a given line index by scanning upward.
|
|
144
|
+
*/
|
|
145
|
+
export function findEnclosingClass(lines, lineIndex) {
|
|
146
|
+
const classRe = /(?:abstract\s+)?class\s+(\w+)/;
|
|
147
|
+
let braceDepth = 0;
|
|
148
|
+
for (let i = lineIndex; i >= 0; i--) {
|
|
149
|
+
const line = lines[i];
|
|
150
|
+
for (let c = line.length - 1; c >= 0; c--) {
|
|
151
|
+
if (line[c] === '}') braceDepth++;
|
|
152
|
+
if (line[c] === '{') braceDepth--;
|
|
153
|
+
}
|
|
154
|
+
const match = line.match(classRe);
|
|
155
|
+
if (match && braceDepth <= 0) return match[1];
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|