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.
@@ -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
+ }