seomd-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/seomd.js +54 -0
- package/package.json +46 -0
- package/src/commands/analyze.js +145 -0
- package/src/commands/init.js +160 -0
- package/src/commands/status.js +192 -0
- package/src/commands/sync.js +122 -0
- package/src/commands/validate.js +58 -0
- package/src/generators/directory.js +136 -0
- package/src/generators/reverse.js +62 -0
- package/src/generators/seomd.js +55 -0
- package/src/templates/blog/SEO.REVERSE.md +176 -0
- package/src/templates/blog/SEO.md +348 -0
- package/src/templates/ecommerce/SEO.REVERSE.md +199 -0
- package/src/templates/ecommerce/SEO.md +354 -0
- package/src/templates/local/SEO.REVERSE.md +222 -0
- package/src/templates/local/SEO.md +360 -0
- package/src/templates/marketplace/SEO.REVERSE.md +222 -0
- package/src/templates/marketplace/SEO.md +360 -0
- package/src/templates/saas/SEO.REVERSE.md +268 -0
- package/src/templates/saas/SEO.md +372 -0
- package/src/utils/api-client.js +41 -0
- package/src/utils/constants.js +132 -0
- package/src/utils/parser.js +46 -0
- package/src/utils/writeback.js +130 -0
- package/src/validators/seomd.js +211 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
import { writeSeoMd } from './parser.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Merges platform analysis blocks back into the SEO.md YAML document.
|
|
8
|
+
*
|
|
9
|
+
* @param {YAML.Document} doc - The YAML Document of SEO.md
|
|
10
|
+
* @param {any} response - The API response from analyze/sync
|
|
11
|
+
* @param {string} cwd - Current working directory
|
|
12
|
+
*/
|
|
13
|
+
export async function writeAnalysisToSeoMd(doc, response, cwd) {
|
|
14
|
+
const lastAnalyzed = response.aeo_analysis.last_analyzed;
|
|
15
|
+
const nextAnalysis = response.aeo_analysis.next_analysis;
|
|
16
|
+
|
|
17
|
+
// 1. Update AEO analysis
|
|
18
|
+
doc.setIn(['aeo', '_analysis', 'source'], 'foxcite');
|
|
19
|
+
doc.setIn(['aeo', '_analysis', 'overall_citation_rate'], response.aeo_analysis.overall_citation_rate);
|
|
20
|
+
doc.setIn(['aeo', '_analysis', 'overall_gap_score'], response.aeo_analysis.overall_gap_score);
|
|
21
|
+
doc.setIn(['aeo', '_analysis', 'engines_tracked'], response.aeo_analysis.engines_tracked);
|
|
22
|
+
doc.setIn(['aeo', '_analysis', 'last_analyzed'], lastAnalyzed);
|
|
23
|
+
doc.setIn(['aeo', '_analysis', 'next_analysis'], nextAnalysis);
|
|
24
|
+
|
|
25
|
+
// 2. Update Intent analysis
|
|
26
|
+
doc.setIn(['intent', '_analysis', 'source'], 'foxcite');
|
|
27
|
+
doc.setIn(['intent', '_analysis', 'last_analyzed'], lastAnalyzed);
|
|
28
|
+
doc.setIn(['intent', '_analysis', 'next_analysis'], nextAnalysis);
|
|
29
|
+
|
|
30
|
+
const categories = ['informational', 'comparison', 'transactional', 'reputational', 'category'];
|
|
31
|
+
for (const cat of categories) {
|
|
32
|
+
if (response.intent_analysis && response.intent_analysis[cat]) {
|
|
33
|
+
doc.setIn(['intent', '_analysis', cat, 'citation_rate'], response.intent_analysis[cat].citation_rate);
|
|
34
|
+
doc.setIn(['intent', '_analysis', cat, 'top_cited_competitor'], response.intent_analysis[cat].top_cited_competitor);
|
|
35
|
+
doc.setIn(['intent', '_analysis', cat, 'gap_score'], response.intent_analysis[cat].gap_score);
|
|
36
|
+
doc.setIn(['intent', '_analysis', cat, 'trend'], response.intent_analysis[cat].trend || 'stable');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 3. Update Pages analysis
|
|
41
|
+
doc.setIn(['pages', '_analysis', 'source'], 'foxcite');
|
|
42
|
+
doc.setIn(['pages', '_analysis', 'last_analyzed'], lastAnalyzed);
|
|
43
|
+
|
|
44
|
+
const pageSummaries = response.page_analysis.map(p => ({
|
|
45
|
+
id: p.id,
|
|
46
|
+
url: p.url,
|
|
47
|
+
citation_rate: p.citation_rate,
|
|
48
|
+
gap_score: p.gap_score,
|
|
49
|
+
top_cited_competitor: p.top_cited_competitor
|
|
50
|
+
}));
|
|
51
|
+
doc.setIn(['pages', '_analysis', 'pages'], pageSummaries);
|
|
52
|
+
doc.setIn(['pages', '_analysis', 'missing_pages'], []);
|
|
53
|
+
doc.setIn(['pages', '_analysis', 'build_order_recommendation'], []);
|
|
54
|
+
|
|
55
|
+
// 4. Update Keywords analysis
|
|
56
|
+
doc.setIn(['keywords', '_analysis', 'source'], 'foxcite');
|
|
57
|
+
doc.setIn(['keywords', '_analysis', 'last_analyzed'], lastAnalyzed);
|
|
58
|
+
doc.setIn(['keywords', '_analysis', 'next_analysis'], nextAnalysis);
|
|
59
|
+
|
|
60
|
+
// Write back to SEO.md
|
|
61
|
+
await writeSeoMd(doc, cwd);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generates and writes the SEO.REVERSE.md file.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} cwd - Current working directory
|
|
68
|
+
* @param {any} response - The API response from analyze/sync
|
|
69
|
+
*/
|
|
70
|
+
export async function writeReverseMd(cwd, response) {
|
|
71
|
+
const reversePath = path.join(cwd, 'SEO.REVERSE.md');
|
|
72
|
+
|
|
73
|
+
const reversePages = response.page_analysis.map(p => ({
|
|
74
|
+
id: p.id,
|
|
75
|
+
url: p.url,
|
|
76
|
+
status: p.status || 'planned',
|
|
77
|
+
citation_rate: p.citation_rate,
|
|
78
|
+
gap_score: p.gap_score,
|
|
79
|
+
top_cited_competitor: p.top_cited_competitor,
|
|
80
|
+
why_page_won: p.why_page_won || [],
|
|
81
|
+
citation_hooks: p.citation_hooks || [],
|
|
82
|
+
gaps_for_my_brand: p.gaps_for_brand || [],
|
|
83
|
+
remediation_playbook: p.remediation_playbook || [],
|
|
84
|
+
fastest_win: p.fastest_win || { action: null, estimated_gap_score_improvement: null, effort: null },
|
|
85
|
+
suggested_content_outline: p.suggested_content_outline || []
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
const primaryCompetitor = response.intent_analysis?.comparison?.top_cited_competitor || 'None';
|
|
89
|
+
|
|
90
|
+
const reverseDoc = {
|
|
91
|
+
domain: response.domain || 'example.com',
|
|
92
|
+
brand: response.brand_name || 'My Brand',
|
|
93
|
+
primary_competitor: primaryCompetitor,
|
|
94
|
+
last_analyzed: response.aeo_analysis.last_analyzed,
|
|
95
|
+
next_analysis: response.aeo_analysis.next_analysis,
|
|
96
|
+
source: 'foxcite',
|
|
97
|
+
pages: reversePages
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const header = `# SEO.REVERSE.md
|
|
101
|
+
#
|
|
102
|
+
# This file is generated by the foxcite platform.
|
|
103
|
+
# Do not edit manually — run \`npx seomd analyze\` to update.
|
|
104
|
+
# Run \`npx seomd sync\` to pull latest intelligence.
|
|
105
|
+
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
const yamlContent = YAML.stringify(reverseDoc);
|
|
109
|
+
await fs.writeFile(reversePath, header + yamlContent, 'utf8');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Writes individual page playbooks into .seomd/pages/{id}.md files.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} cwd - Current working directory
|
|
116
|
+
* @param {any} response - The API response from analyze/sync
|
|
117
|
+
*/
|
|
118
|
+
export async function writePageAnalysis(cwd, response) {
|
|
119
|
+
const seomdDir = path.join(cwd, '.seomd');
|
|
120
|
+
const pagesDir = path.join(seomdDir, 'pages');
|
|
121
|
+
|
|
122
|
+
await fs.ensureDir(pagesDir);
|
|
123
|
+
|
|
124
|
+
for (const page of response.page_analysis) {
|
|
125
|
+
if (page.markdown_content) {
|
|
126
|
+
const filePath = path.join(pagesDir, `${page.id}.md`);
|
|
127
|
+
await fs.writeFile(filePath, page.markdown_content, 'utf8');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { SITE_TYPES, INTENT_CATEGORIES } from '../utils/constants.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates a parsed seo.md configuration object against the spec.
|
|
5
|
+
*
|
|
6
|
+
* @param {any} data - The parsed seo.md JS object
|
|
7
|
+
* @returns {{errors: Array<{path: string, message: string}>, warnings: Array<{path: string, message: string}>}} Validation results
|
|
8
|
+
*/
|
|
9
|
+
export function validateSeoMd(data) {
|
|
10
|
+
const errors = [];
|
|
11
|
+
const warnings = [];
|
|
12
|
+
|
|
13
|
+
if (!data || typeof data !== 'object') {
|
|
14
|
+
errors.push({ path: '', message: 'Specification must be a valid YAML object' });
|
|
15
|
+
return { errors, warnings };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 1. Required top-level sections
|
|
19
|
+
const requiredSections = [
|
|
20
|
+
'site',
|
|
21
|
+
'identity',
|
|
22
|
+
'keywords',
|
|
23
|
+
'intent',
|
|
24
|
+
'pages',
|
|
25
|
+
'schema',
|
|
26
|
+
'crawl',
|
|
27
|
+
'performance',
|
|
28
|
+
'monitoring'
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
for (const section of requiredSections) {
|
|
32
|
+
if (!(section in data) || data[section] === null) {
|
|
33
|
+
errors.push({ path: section, message: `Missing required top-level section: '${section}'` });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// If critical sections are missing, stop early to avoid nested property access errors
|
|
38
|
+
if (errors.some(e => ['site', 'identity', 'keywords', 'intent', 'pages'].includes(e.path))) {
|
|
39
|
+
return { errors, warnings };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. Validate 'site' section
|
|
43
|
+
const site = data.site || {};
|
|
44
|
+
const validSiteTypes = SITE_TYPES.map(t => t.value);
|
|
45
|
+
|
|
46
|
+
if (!site.type) {
|
|
47
|
+
errors.push({ path: 'site.type', message: 'site.type is required' });
|
|
48
|
+
} else if (!validSiteTypes.includes(site.type)) {
|
|
49
|
+
errors.push({
|
|
50
|
+
path: 'site.type',
|
|
51
|
+
message: `Invalid site.type '${site.type}'. Must be one of: ${validSiteTypes.join(', ')}`
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!site.domain) {
|
|
56
|
+
errors.push({ path: 'site.domain', message: 'site.domain is required' });
|
|
57
|
+
} else if (typeof site.domain !== 'string') {
|
|
58
|
+
errors.push({ path: 'site.domain', message: 'site.domain must be a string' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (site.canonical && typeof site.canonical !== 'string') {
|
|
62
|
+
errors.push({ path: 'site.canonical', message: 'site.canonical must be a string' });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. Validate 'identity' section
|
|
66
|
+
const identity = data.identity || {};
|
|
67
|
+
if (!identity.brand) {
|
|
68
|
+
errors.push({ path: 'identity.brand', message: 'identity.brand is required' });
|
|
69
|
+
} else if (typeof identity.brand !== 'string') {
|
|
70
|
+
errors.push({ path: 'identity.brand', message: 'identity.brand must be a string' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!identity.tagline) {
|
|
74
|
+
warnings.push({ path: 'identity.tagline', message: 'identity.tagline is missing or empty' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 4. Validate 'keywords' section
|
|
78
|
+
const keywords = data.keywords || {};
|
|
79
|
+
if (!keywords.primary) {
|
|
80
|
+
errors.push({ path: 'keywords.primary', message: 'keywords.primary is required' });
|
|
81
|
+
} else if (typeof keywords.primary !== 'string') {
|
|
82
|
+
errors.push({ path: 'keywords.primary', message: 'keywords.primary must be a string' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!keywords.secondary || !Array.isArray(keywords.secondary) || keywords.secondary.length === 0) {
|
|
86
|
+
warnings.push({ path: 'keywords.secondary', message: 'keywords.secondary is empty. Consider adding target secondary terms.' });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!keywords.negative || !Array.isArray(keywords.negative)) {
|
|
90
|
+
warnings.push({ path: 'keywords.negative', message: 'keywords.negative should be an array of terms to filter out' });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!keywords.long_tail || !Array.isArray(keywords.long_tail) || keywords.long_tail.length === 0) {
|
|
94
|
+
warnings.push({ path: 'keywords.long_tail', message: 'keywords.long_tail is empty. Consider adding long-tail variations.' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 5. Validate 'intent' section
|
|
98
|
+
const intent = data.intent || {};
|
|
99
|
+
for (const cat of INTENT_CATEGORIES) {
|
|
100
|
+
const catData = intent[cat];
|
|
101
|
+
if (!catData || typeof catData !== 'object') {
|
|
102
|
+
errors.push({ path: `intent.${cat}`, message: `Missing or invalid intent category: 'intent.${cat}'` });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const validPriorities = ['low', 'medium', 'high', 'critical'];
|
|
107
|
+
if (!catData.priority) {
|
|
108
|
+
errors.push({ path: `intent.${cat}.priority`, message: `intent.${cat}.priority is required` });
|
|
109
|
+
} else if (!validPriorities.includes(String(catData.priority).toLowerCase())) {
|
|
110
|
+
errors.push({
|
|
111
|
+
path: `intent.${cat}.priority`,
|
|
112
|
+
message: `Invalid priority '${catData.priority}' for intent.${cat}. Must be one of: ${validPriorities.join(', ')}`
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!catData.queries || !Array.isArray(catData.queries)) {
|
|
117
|
+
errors.push({ path: `intent.${cat}.queries`, message: `intent.${cat}.queries must be an array` });
|
|
118
|
+
} else if (catData.queries.length === 0) {
|
|
119
|
+
warnings.push({ path: `intent.${cat}.queries`, message: `intent.${cat}.queries list is empty. Add queries to track.` });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 6. Validate 'pages' section
|
|
124
|
+
const pages = data.pages || {};
|
|
125
|
+
if (!pages.required || !Array.isArray(pages.required)) {
|
|
126
|
+
errors.push({ path: 'pages.required', message: 'pages.required must be an array' });
|
|
127
|
+
} else if (pages.required.length === 0) {
|
|
128
|
+
errors.push({ path: 'pages.required', message: 'pages.required array cannot be empty' });
|
|
129
|
+
} else {
|
|
130
|
+
pages.required.forEach((page, index) => {
|
|
131
|
+
const prefix = `pages.required[${index}]`;
|
|
132
|
+
if (!page || typeof page !== 'object') {
|
|
133
|
+
errors.push({ path: prefix, message: `Page entry at index ${index} must be an object` });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!page.id) {
|
|
138
|
+
errors.push({ path: `${prefix}.id`, message: `Page at index ${index} is missing 'id'` });
|
|
139
|
+
}
|
|
140
|
+
if (!page.url) {
|
|
141
|
+
errors.push({ path: `${prefix}.url`, message: `Page '${page.id || index}' is missing 'url'` });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const validStatus = ['live', 'draft', 'planned'];
|
|
145
|
+
if (!page.status) {
|
|
146
|
+
errors.push({ path: `${prefix}.status`, message: `Page '${page.id || index}' is missing 'status'` });
|
|
147
|
+
} else if (!validStatus.includes(page.status)) {
|
|
148
|
+
errors.push({
|
|
149
|
+
path: `${prefix}.status`,
|
|
150
|
+
message: `Invalid status '${page.status}' for page '${page.id || index}'. Must be one of: ${validStatus.join(', ')}`
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (page.priority === undefined || page.priority === null) {
|
|
155
|
+
errors.push({ path: `${prefix}.priority`, message: `Page '${page.id || index}' is missing 'priority'` });
|
|
156
|
+
} else if (typeof page.priority !== 'number') {
|
|
157
|
+
errors.push({ path: `${prefix}.priority`, message: `Priority for page '${page.id || index}' must be a number` });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 7. Validate 'schema' section
|
|
163
|
+
const schema = data.schema || {};
|
|
164
|
+
if (schema.types && !Array.isArray(schema.types)) {
|
|
165
|
+
errors.push({ path: 'schema.types', message: 'schema.types must be an array' });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 8. Validate 'crawl' section
|
|
169
|
+
const crawl = data.crawl || {};
|
|
170
|
+
if (crawl.sitemap && typeof crawl.sitemap !== 'string') {
|
|
171
|
+
errors.push({ path: 'crawl.sitemap', message: 'crawl.sitemap must be a string' });
|
|
172
|
+
}
|
|
173
|
+
if (crawl.robots_txt && typeof crawl.robots_txt !== 'string') {
|
|
174
|
+
errors.push({ path: 'crawl.robots_txt', message: 'crawl.robots_txt must be a string' });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 9. Validate 'performance' section
|
|
178
|
+
const perf = data.performance || {};
|
|
179
|
+
const stringFields = ['lcp', 'cls', 'fid', 'page_size', 'ttfb'];
|
|
180
|
+
for (const field of stringFields) {
|
|
181
|
+
if (perf[field] !== undefined && perf[field] !== null && typeof perf[field] !== 'string' && typeof perf[field] !== 'number') {
|
|
182
|
+
errors.push({ path: `performance.${field}`, message: `performance.${field} must be a string or number` });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 10. Validate 'authority' section
|
|
187
|
+
const authority = data.authority || {};
|
|
188
|
+
if (authority.eeat_signals) {
|
|
189
|
+
const eeat = authority.eeat_signals;
|
|
190
|
+
if (typeof eeat === 'object') {
|
|
191
|
+
const fields = ['experience', 'expertise', 'authority', 'trust'];
|
|
192
|
+
const missingEeat = fields.filter(f => !eeat[f]);
|
|
193
|
+
if (missingEeat.length > 0) {
|
|
194
|
+
warnings.push({
|
|
195
|
+
path: 'authority.eeat_signals',
|
|
196
|
+
message: `EEAT signals are missing values for: ${missingEeat.join(', ')}`
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
warnings.push({ path: 'authority.eeat_signals', message: 'authority.eeat_signals section is missing. Highly recommended for AEO.' });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 11. Validate 'platform' section
|
|
205
|
+
const platform = data.platform || {};
|
|
206
|
+
if (!platform.provider) {
|
|
207
|
+
warnings.push({ path: 'platform.provider', message: 'platform.provider is null. CLI requires connection for analyze/sync.' });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { errors, warnings };
|
|
211
|
+
}
|