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.
@@ -0,0 +1,128 @@
1
+ import fg from 'fast-glob';
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import {
5
+ upsertDoc,
6
+ markDocsRemoved,
7
+ getIndexedFile,
8
+ upsertIndexedFile,
9
+ getActiveDocId,
10
+ } from '../db/sqlite.js';
11
+ import { DocParserRegistry, extractFrontmatter } from './parsers/base-doc-parser.js';
12
+ import { BlockEditorParser } from './parsers/block-editor-parser.js';
13
+ import { PluginHandbookParser } from './parsers/plugin-handbook-parser.js';
14
+ import { RestApiParser } from './parsers/rest-api-parser.js';
15
+ import { WpCliParser } from './parsers/wp-cli-parser.js';
16
+ import { AdminHandbookParser } from './parsers/admin-handbook-parser.js';
17
+ import { GeneralDocParser } from './parsers/general-doc-parser.js';
18
+
19
+ const MD_PATTERNS = ['**/*.md'];
20
+
21
+ const IGNORE_PATTERNS = [
22
+ '**/node_modules/**',
23
+ '**/.git/**',
24
+ '**/vendor/**',
25
+ '**/images/**',
26
+ '**/img/**',
27
+ '**/assets/**',
28
+ '**/static/**',
29
+ '**/CHANGELOG.md',
30
+ '**/changelog.md',
31
+ '**/CODE_OF_CONDUCT.md',
32
+ '**/CONTRIBUTING.md',
33
+ '**/LICENSE.md',
34
+ ];
35
+
36
+ // Build the parser registry — specific parsers first, general fallback last
37
+ const registry = new DocParserRegistry();
38
+ registry.register(new BlockEditorParser());
39
+ registry.register(new PluginHandbookParser());
40
+ registry.register(new RestApiParser());
41
+ registry.register(new WpCliParser());
42
+ registry.register(new AdminHandbookParser());
43
+ registry.register(new GeneralDocParser());
44
+
45
+ /**
46
+ * Index markdown documentation from a docs-type source.
47
+ * Mirrors the pattern of indexSource() in index-manager.js.
48
+ */
49
+ export async function indexDocsSource(source, localPath, force, stats) {
50
+ const files = await fg(MD_PATTERNS, {
51
+ cwd: localPath,
52
+ ignore: IGNORE_PATTERNS,
53
+ absolute: false,
54
+ onlyFiles: true,
55
+ });
56
+
57
+ console.error(`Found ${files.length} markdown files to check in ${source.name}`);
58
+
59
+ const activeDocIds = [];
60
+
61
+ for (const file of files) {
62
+ const fullPath = `${localPath}/${file}`;
63
+
64
+ try {
65
+ const fileStat = statSync(fullPath);
66
+ const mtimeMs = fileStat.mtimeMs;
67
+
68
+ // Cache indexed file lookup (used for both mtime and content hash checks)
69
+ const indexed = force ? null : getIndexedFile(source.id, file);
70
+
71
+ // Check mtime for incremental skip
72
+ if (indexed && indexed.mtime_ms === mtimeMs) {
73
+ const docId = getActiveDocId(source.id, file);
74
+ if (docId) activeDocIds.push(docId);
75
+ stats.files_skipped++;
76
+ continue;
77
+ }
78
+
79
+ const content = readFileSync(fullPath, 'utf-8');
80
+ const contentHash = createHash('sha256').update(content).digest('hex').slice(0, 16);
81
+
82
+ // Skip if content hash matches
83
+ if (indexed && indexed.content_hash === contentHash) {
84
+ const docId = getActiveDocId(source.id, file);
85
+ if (docId) activeDocIds.push(docId);
86
+ upsertIndexedFile(source.id, file, mtimeMs, contentHash);
87
+ stats.files_skipped++;
88
+ continue;
89
+ }
90
+
91
+ // Extract frontmatter for parser selection
92
+ const { frontmatter } = extractFrontmatter(content);
93
+
94
+ // Select parser
95
+ const parser = registry.getParser(file, frontmatter, source.name);
96
+ if (!parser) {
97
+ // Should never happen with GeneralDocParser as fallback
98
+ continue;
99
+ }
100
+
101
+ // Parse
102
+ const docData = parser.parse(content, file, source.id);
103
+ if (!docData || !docData.title) {
104
+ continue;
105
+ }
106
+
107
+ // Upsert
108
+ const result = upsertDoc(docData);
109
+ activeDocIds.push(result.id);
110
+
111
+ if (result.action === 'inserted') stats.docs_inserted++;
112
+ else if (result.action === 'updated') stats.docs_updated++;
113
+ else stats.docs_skipped++;
114
+
115
+ // Track file
116
+ upsertIndexedFile(source.id, file, mtimeMs, contentHash);
117
+ stats.files_processed++;
118
+ } catch (err) {
119
+ const msg = `Error indexing doc ${file}: ${err.message}`;
120
+ console.error(msg);
121
+ stats.errors.push(msg);
122
+ }
123
+ }
124
+
125
+ // Soft-delete docs no longer found in this source
126
+ const removed = markDocsRemoved(source.id, activeDocIds);
127
+ stats.docs_removed += removed;
128
+ }
@@ -0,0 +1,48 @@
1
+ import { BaseDocParser } from './base-doc-parser.js';
2
+
3
+ export class AdminHandbookParser extends BaseDocParser {
4
+ canParse(filePath, frontmatter, sourceName) {
5
+ return sourceName.includes('admin-handbook') || sourceName === 'admin';
6
+ }
7
+
8
+ parse(content, filePath, sourceId) {
9
+ const { frontmatter, body } = this.extractFrontmatter(content);
10
+ const title = this.extractTitle(body, frontmatter) || filePath;
11
+ const description = this.extractDescription(body);
12
+ const codeExamples = this.extractCodeExamples(body);
13
+
14
+ const pathParts = filePath.split('/');
15
+ const subcategory = pathParts.length > 1 ? pathParts[0] : null;
16
+
17
+ // Extract config snippets (wp-config.php defines, .htaccess, nginx)
18
+ const configSnippets = [];
19
+ const defineRegex = /define\s*\(\s*['"](\w+)['"]/g;
20
+ let match;
21
+ while ((match = defineRegex.exec(body)) !== null) {
22
+ if (!configSnippets.includes(match[1])) {
23
+ configSnippets.push(match[1]);
24
+ }
25
+ }
26
+
27
+ const metadata = {};
28
+ if (configSnippets.length > 0) metadata.config_defines = configSnippets;
29
+ if (Object.keys(frontmatter).length > 0) metadata.frontmatter = frontmatter;
30
+
31
+ const doc = {
32
+ source_id: sourceId,
33
+ file_path: filePath,
34
+ slug: this.generateSlug(filePath),
35
+ title,
36
+ doc_type: this.inferDocType(body, frontmatter),
37
+ category: 'admin',
38
+ subcategory,
39
+ description,
40
+ content: body,
41
+ code_examples: codeExamples,
42
+ metadata: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null,
43
+ };
44
+
45
+ doc.content_hash = this.generateContentHash(doc);
46
+ return doc;
47
+ }
48
+ }
@@ -0,0 +1,175 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ /**
4
+ * Base class for document parsers. Each parser handles a specific type of
5
+ * WordPress documentation (handbook, REST API reference, WP-CLI, etc.).
6
+ */
7
+ export class BaseDocParser {
8
+ /**
9
+ * Check if this parser can handle the given file.
10
+ * @param {string} filePath - Relative file path
11
+ * @param {object} frontmatter - Parsed frontmatter data
12
+ * @param {string} sourceName - The source name from DB
13
+ * @returns {boolean}
14
+ */
15
+ canParse(filePath, frontmatter, sourceName) {
16
+ throw new Error('canParse() must be implemented by subclass');
17
+ }
18
+
19
+ /**
20
+ * Parse a markdown file into a structured doc object.
21
+ * @param {string} content - Raw file content
22
+ * @param {string} filePath - Relative file path
23
+ * @param {number} sourceId - Source ID from DB
24
+ * @returns {object} Parsed doc data ready for upsertDoc()
25
+ */
26
+ parse(content, filePath, sourceId) {
27
+ throw new Error('parse() must be implemented by subclass');
28
+ }
29
+
30
+ // --- Shared utilities ---
31
+
32
+ extractFrontmatter(content) {
33
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
34
+ if (!match) return { frontmatter: {}, body: content };
35
+
36
+ const raw = match[1];
37
+ const frontmatter = {};
38
+ for (const line of raw.split('\n')) {
39
+ const sep = line.indexOf(':');
40
+ if (sep === -1) continue;
41
+ const key = line.slice(0, sep).trim();
42
+ const val = line.slice(sep + 1).trim().replace(/^["']|["']$/g, '');
43
+ if (key) frontmatter[key] = val;
44
+ }
45
+
46
+ const body = content.slice(match[0].length).trim();
47
+ return { frontmatter, body };
48
+ }
49
+
50
+ extractTitle(body, frontmatter) {
51
+ if (frontmatter.title) return frontmatter.title;
52
+ const h1 = body.match(/^#\s+(.+)/m);
53
+ if (h1) return h1[1].trim();
54
+ return null;
55
+ }
56
+
57
+ extractDescription(body) {
58
+ // First non-heading, non-empty paragraph
59
+ const lines = body.split('\n');
60
+ let collecting = false;
61
+ const desc = [];
62
+
63
+ for (const line of lines) {
64
+ const trimmed = line.trim();
65
+ if (!trimmed) {
66
+ if (collecting) break;
67
+ continue;
68
+ }
69
+ if (trimmed.startsWith('#') || trimmed.startsWith('---') || trimmed.startsWith('```')) {
70
+ if (collecting) break;
71
+ continue;
72
+ }
73
+ collecting = true;
74
+ desc.push(trimmed);
75
+ }
76
+
77
+ const result = desc.join(' ').slice(0, 500);
78
+ return result || null;
79
+ }
80
+
81
+ extractCodeExamples(body) {
82
+ const examples = [];
83
+ const regex = /```(\w*)\n([\s\S]*?)```/g;
84
+ let match;
85
+ while ((match = regex.exec(body)) !== null) {
86
+ examples.push({
87
+ language: match[1] || 'text',
88
+ code: match[2].trim(),
89
+ });
90
+ }
91
+ return examples.length > 0 ? JSON.stringify(examples) : null;
92
+ }
93
+
94
+ generateSlug(filePath) {
95
+ return filePath
96
+ .toLowerCase()
97
+ .replace(/\.md$/i, '')
98
+ .replace(/\\/g, '/')
99
+ .replace(/[^a-z0-9/_-]/g, '-')
100
+ .replace(/-+/g, '-')
101
+ .replace(/\//g, '--')
102
+ .replace(/^-|-$/g, '');
103
+ }
104
+
105
+ generateContentHash(data) {
106
+ const hash = createHash('sha256');
107
+ hash.update(JSON.stringify({
108
+ title: data.title,
109
+ content: data.content,
110
+ doc_type: data.doc_type,
111
+ category: data.category,
112
+ }));
113
+ return hash.digest('hex').slice(0, 16);
114
+ }
115
+
116
+ inferDocType(body, frontmatter) {
117
+ const text = (body + ' ' + (frontmatter.title || '')).toLowerCase();
118
+
119
+ if (frontmatter.doc_type) return frontmatter.doc_type;
120
+
121
+ if (/\breference\b/.test(text) || /\bapi\b/.test(text)) return 'reference';
122
+ if (/\btutorial\b/.test(text) || /\bstep[- ]by[- ]step\b/.test(text)) return 'tutorial';
123
+ if (/\bhow[- ]to\b/.test(text)) return 'howto';
124
+ if (/\bfaq\b/i.test(text) || /\bfrequently asked\b/.test(text)) return 'faq';
125
+ if (/\bguide\b/.test(text)) return 'guide';
126
+
127
+ return 'general';
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Standalone frontmatter extraction — usable without instantiating a parser.
133
+ * @param {string} content - Raw file content
134
+ * @returns {{ frontmatter: object, body: string }}
135
+ */
136
+ export function extractFrontmatter(content) {
137
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
138
+ if (!match) return { frontmatter: {}, body: content };
139
+
140
+ const raw = match[1];
141
+ const frontmatter = {};
142
+ for (const line of raw.split('\n')) {
143
+ const sep = line.indexOf(':');
144
+ if (sep === -1) continue;
145
+ const key = line.slice(0, sep).trim();
146
+ const val = line.slice(sep + 1).trim().replace(/^["']|["']$/g, '');
147
+ if (key) frontmatter[key] = val;
148
+ }
149
+
150
+ const body = content.slice(match[0].length).trim();
151
+ return { frontmatter, body };
152
+ }
153
+
154
+ /**
155
+ * Registry that selects the appropriate parser for a given file.
156
+ * Parsers are checked in order — first match wins.
157
+ */
158
+ export class DocParserRegistry {
159
+ constructor() {
160
+ this.parsers = [];
161
+ }
162
+
163
+ register(parser) {
164
+ this.parsers.push(parser);
165
+ }
166
+
167
+ getParser(filePath, frontmatter, sourceName) {
168
+ for (const parser of this.parsers) {
169
+ if (parser.canParse(filePath, frontmatter, sourceName)) {
170
+ return parser;
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+ }
@@ -0,0 +1,49 @@
1
+ import { BaseDocParser } from './base-doc-parser.js';
2
+
3
+ export class BlockEditorParser extends BaseDocParser {
4
+ canParse(filePath, frontmatter, sourceName) {
5
+ return sourceName.includes('gutenberg') && filePath.startsWith('docs/');
6
+ }
7
+
8
+ parse(content, filePath, sourceId) {
9
+ const { frontmatter, body } = this.extractFrontmatter(content);
10
+ const title = this.extractTitle(body, frontmatter) || filePath;
11
+ const description = this.extractDescription(body);
12
+ const codeExamples = this.extractCodeExamples(body);
13
+
14
+ // Infer subcategory from path: docs/getting-started/..., docs/reference-guides/..., etc.
15
+ const pathParts = filePath.split('/');
16
+ const subcategory = pathParts.length > 2 ? pathParts[1] : null;
17
+
18
+ // Extract @wordpress/ package references
19
+ const packageRefs = [];
20
+ const pkgRegex = /@wordpress\/[\w-]+/g;
21
+ let match;
22
+ while ((match = pkgRegex.exec(body)) !== null) {
23
+ if (!packageRefs.includes(match[0])) {
24
+ packageRefs.push(match[0]);
25
+ }
26
+ }
27
+
28
+ const metadata = {};
29
+ if (packageRefs.length > 0) metadata.package_refs = packageRefs;
30
+ if (Object.keys(frontmatter).length > 0) metadata.frontmatter = frontmatter;
31
+
32
+ const doc = {
33
+ source_id: sourceId,
34
+ file_path: filePath,
35
+ slug: this.generateSlug(filePath),
36
+ title,
37
+ doc_type: this.inferDocType(body, frontmatter),
38
+ category: 'block-editor',
39
+ subcategory,
40
+ description,
41
+ content: body,
42
+ code_examples: codeExamples,
43
+ metadata: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null,
44
+ };
45
+
46
+ doc.content_hash = this.generateContentHash(doc);
47
+ return doc;
48
+ }
49
+ }
@@ -0,0 +1,53 @@
1
+ import { BaseDocParser } from './base-doc-parser.js';
2
+
3
+ /**
4
+ * Fallback parser for any markdown doc that doesn't match a specialized parser.
5
+ * Always returns true from canParse() — must be registered last in the registry.
6
+ */
7
+ export class GeneralDocParser extends BaseDocParser {
8
+ canParse() {
9
+ return true;
10
+ }
11
+
12
+ parse(content, filePath, sourceId) {
13
+ const { frontmatter, body } = this.extractFrontmatter(content);
14
+ const title = this.extractTitle(body, frontmatter) || filePath;
15
+ const description = this.extractDescription(body);
16
+ const codeExamples = this.extractCodeExamples(body);
17
+
18
+ const pathParts = filePath.split('/');
19
+ const subcategory = pathParts.length > 1 ? pathParts[0] : null;
20
+
21
+ // Try to infer category from path or frontmatter
22
+ let category = frontmatter.category || null;
23
+ if (!category) {
24
+ const pathLower = filePath.toLowerCase();
25
+ if (pathLower.includes('block') || pathLower.includes('gutenberg')) category = 'block-editor';
26
+ else if (pathLower.includes('plugin')) category = 'plugins';
27
+ else if (pathLower.includes('rest') || pathLower.includes('api')) category = 'rest-api';
28
+ else if (pathLower.includes('cli')) category = 'wp-cli';
29
+ else if (pathLower.includes('admin')) category = 'admin';
30
+ }
31
+
32
+ const metadata = Object.keys(frontmatter).length > 0
33
+ ? JSON.stringify({ frontmatter })
34
+ : null;
35
+
36
+ const doc = {
37
+ source_id: sourceId,
38
+ file_path: filePath,
39
+ slug: this.generateSlug(filePath),
40
+ title,
41
+ doc_type: this.inferDocType(body, frontmatter),
42
+ category,
43
+ subcategory,
44
+ description,
45
+ content: body,
46
+ code_examples: codeExamples,
47
+ metadata,
48
+ };
49
+
50
+ doc.content_hash = this.generateContentHash(doc);
51
+ return doc;
52
+ }
53
+ }
@@ -0,0 +1,60 @@
1
+ import { BaseDocParser } from './base-doc-parser.js';
2
+
3
+ export class PluginHandbookParser extends BaseDocParser {
4
+ canParse(filePath, frontmatter, sourceName) {
5
+ return sourceName.includes('plugin-handbook') || sourceName === 'plugin';
6
+ }
7
+
8
+ parse(content, filePath, sourceId) {
9
+ const { frontmatter, body } = this.extractFrontmatter(content);
10
+ const title = this.extractTitle(body, frontmatter) || filePath;
11
+ const description = this.extractDescription(body);
12
+ const codeExamples = this.extractCodeExamples(body);
13
+
14
+ // Infer subcategory from directory structure
15
+ const pathParts = filePath.split('/');
16
+ const subcategory = pathParts.length > 1 ? pathParts[0] : null;
17
+
18
+ // Extract WP function references
19
+ const funcRefs = [];
20
+ const funcRegex = /\b(wp_\w+|get_\w+|add_\w+|remove_\w+|do_action|apply_filters|register_\w+)\s*\(/g;
21
+ let match;
22
+ while ((match = funcRegex.exec(body)) !== null) {
23
+ if (!funcRefs.includes(match[1])) {
24
+ funcRefs.push(match[1]);
25
+ }
26
+ }
27
+
28
+ // Extract hook names mentioned in content
29
+ const hookRefs = [];
30
+ const hookRegex = /['"`]([a-z_]+(?:\{[^}]*\})?[a-z_]*)['"`]\s*(?:,|\))/g;
31
+ while ((match = hookRegex.exec(body)) !== null) {
32
+ const name = match[1];
33
+ if (name.includes('_') && name.length > 3 && !hookRefs.includes(name)) {
34
+ hookRefs.push(name);
35
+ }
36
+ }
37
+
38
+ const metadata = {};
39
+ if (funcRefs.length > 0) metadata.function_refs = funcRefs;
40
+ if (hookRefs.length > 0) metadata.hook_refs = hookRefs;
41
+ if (Object.keys(frontmatter).length > 0) metadata.frontmatter = frontmatter;
42
+
43
+ const doc = {
44
+ source_id: sourceId,
45
+ file_path: filePath,
46
+ slug: this.generateSlug(filePath),
47
+ title,
48
+ doc_type: this.inferDocType(body, frontmatter),
49
+ category: 'plugins',
50
+ subcategory,
51
+ description,
52
+ content: body,
53
+ code_examples: codeExamples,
54
+ metadata: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null,
55
+ };
56
+
57
+ doc.content_hash = this.generateContentHash(doc);
58
+ return doc;
59
+ }
60
+ }
@@ -0,0 +1,57 @@
1
+ import { BaseDocParser } from './base-doc-parser.js';
2
+
3
+ export class RestApiParser extends BaseDocParser {
4
+ canParse(filePath, frontmatter, sourceName) {
5
+ return sourceName.includes('rest-api') || sourceName.includes('wp-api');
6
+ }
7
+
8
+ parse(content, filePath, sourceId) {
9
+ const { frontmatter, body } = this.extractFrontmatter(content);
10
+ const title = this.extractTitle(body, frontmatter) || filePath;
11
+ const description = this.extractDescription(body);
12
+ const codeExamples = this.extractCodeExamples(body);
13
+
14
+ const pathParts = filePath.split('/');
15
+ const subcategory = pathParts.length > 1 ? pathParts[0] : null;
16
+
17
+ // Extract endpoint definitions (method + route)
18
+ const endpoints = [];
19
+ const endpointRegex = /\b(GET|POST|PUT|PATCH|DELETE)\s+(`?\/wp\/v2\/[\w\/-{}]+`?|`?\/wp-json\/[\w\/-{}]+`?)/g;
20
+ let match;
21
+ while ((match = endpointRegex.exec(body)) !== null) {
22
+ endpoints.push({ method: match[1], route: match[2].replace(/`/g, '') });
23
+ }
24
+
25
+ // Also look for route definitions in code blocks
26
+ const routeRegex = /['"]\/wp\/v2\/[\w\/-{}]+['"]/g;
27
+ while ((match = routeRegex.exec(body)) !== null) {
28
+ const route = match[0].replace(/['"]/g, '');
29
+ if (!endpoints.find(e => e.route === route)) {
30
+ endpoints.push({ method: 'ANY', route });
31
+ }
32
+ }
33
+
34
+ const metadata = {};
35
+ if (endpoints.length > 0) metadata.endpoints = endpoints;
36
+ if (Object.keys(frontmatter).length > 0) metadata.frontmatter = frontmatter;
37
+
38
+ const docType = endpoints.length > 0 ? 'api' : this.inferDocType(body, frontmatter);
39
+
40
+ const doc = {
41
+ source_id: sourceId,
42
+ file_path: filePath,
43
+ slug: this.generateSlug(filePath),
44
+ title,
45
+ doc_type: docType,
46
+ category: 'rest-api',
47
+ subcategory,
48
+ description,
49
+ content: body,
50
+ code_examples: codeExamples,
51
+ metadata: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null,
52
+ };
53
+
54
+ doc.content_hash = this.generateContentHash(doc);
55
+ return doc;
56
+ }
57
+ }
@@ -0,0 +1,61 @@
1
+ import { BaseDocParser } from './base-doc-parser.js';
2
+
3
+ export class WpCliParser extends BaseDocParser {
4
+ canParse(filePath, frontmatter, sourceName) {
5
+ return sourceName.includes('wp-cli');
6
+ }
7
+
8
+ parse(content, filePath, sourceId) {
9
+ const { frontmatter, body } = this.extractFrontmatter(content);
10
+ const title = this.extractTitle(body, frontmatter) || filePath;
11
+ const description = this.extractDescription(body);
12
+ const codeExamples = this.extractCodeExamples(body);
13
+
14
+ const pathParts = filePath.split('/');
15
+ const subcategory = pathParts.length > 1 ? pathParts[0] : null;
16
+
17
+ // Extract command signatures like "wp <command> <subcommand> [--flag]"
18
+ const commands = [];
19
+ const cmdRegex = /(?:^|\n)\s*(?:#+\s*)?`?(wp\s+[\w-]+(?:\s+[\w-]+)?)`?/g;
20
+ let match;
21
+ while ((match = cmdRegex.exec(body)) !== null) {
22
+ const cmd = match[1].trim();
23
+ if (!commands.includes(cmd)) {
24
+ commands.push(cmd);
25
+ }
26
+ }
27
+
28
+ // Extract options/flags like --option=<value>
29
+ const options = [];
30
+ const optRegex = /--[\w-]+(?:=<[^>]+>)?/g;
31
+ while ((match = optRegex.exec(body)) !== null) {
32
+ if (!options.includes(match[0])) {
33
+ options.push(match[0]);
34
+ }
35
+ }
36
+
37
+ const metadata = {};
38
+ if (commands.length > 0) metadata.commands = commands;
39
+ if (options.length > 0) metadata.options = options;
40
+ if (Object.keys(frontmatter).length > 0) metadata.frontmatter = frontmatter;
41
+
42
+ const docType = commands.length > 0 ? 'reference' : this.inferDocType(body, frontmatter);
43
+
44
+ const doc = {
45
+ source_id: sourceId,
46
+ file_path: filePath,
47
+ slug: this.generateSlug(filePath),
48
+ title,
49
+ doc_type: docType,
50
+ category: 'wp-cli',
51
+ subcategory,
52
+ description,
53
+ content: body,
54
+ code_examples: codeExamples,
55
+ metadata: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null,
56
+ };
57
+
58
+ doc.content_hash = this.generateContentHash(doc);
59
+ return doc;
60
+ }
61
+ }