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,122 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+ import dotenv from 'dotenv';
6
+ import { parseSeoMd } from '../utils/parser.js';
7
+ import { client } from '../utils/api-client.js';
8
+ import { writeAnalysisToSeoMd, writeReverseMd, writePageAnalysis } from '../utils/writeback.js';
9
+
10
+ dotenv.config();
11
+
12
+ export async function syncCommand(options) {
13
+ const apiKey = process.env.SEOMD_API_KEY;
14
+ const paymentToken = process.env.SEOMD_PAYMENT_TOKEN;
15
+
16
+ if (!apiKey && !paymentToken) {
17
+ console.log('');
18
+ console.log(chalk.yellow('⚠ No API key or payment token found.'));
19
+ console.log('');
20
+ console.log('Add your API key to .env:');
21
+ console.log(chalk.cyan(' SEOMD_API_KEY=your_key_here'));
22
+ console.log('');
23
+ console.log('Get your API key at ' + chalk.cyan('https://seomd.dev/connect'));
24
+ console.log('');
25
+ process.exit(1);
26
+ }
27
+
28
+ const cwd = process.cwd();
29
+ let doc, data;
30
+
31
+ try {
32
+ const parsed = await parseSeoMd(cwd);
33
+ doc = parsed.doc;
34
+ data = parsed.data;
35
+ } catch (err) {
36
+ console.log(chalk.red(`\nāŒ Error: ${err.message}`));
37
+ process.exit(1);
38
+ }
39
+
40
+ const domain = data.site?.domain;
41
+ if (!domain) {
42
+ console.log(chalk.red('\nāŒ Error: "site.domain" is required in SEO.md.'));
43
+ process.exit(1);
44
+ }
45
+
46
+ // Extract pages
47
+ let pagesList = [];
48
+ if (data.pages) {
49
+ if (Array.isArray(data.pages.required)) {
50
+ pagesList = pagesList.concat(data.pages.required);
51
+ }
52
+ if (Array.isArray(data.pages.optional)) {
53
+ pagesList = pagesList.concat(data.pages.optional);
54
+ }
55
+ }
56
+
57
+ console.log(chalk.bold.cyan(`\nšŸ”„ foxcite: Syncing AI Search Audit for ${chalk.white(domain)}`));
58
+ const spinner = ora('Fetching cached analysis from platform...').start();
59
+
60
+ try {
61
+ const pagesParam = JSON.stringify(pagesList.map(p => ({
62
+ id: p.id,
63
+ url: p.url,
64
+ primary_keyword: p.primary_keyword
65
+ })));
66
+
67
+ const response = await client.get('/cli/sync', {
68
+ params: { domain, pages: pagesParam }
69
+ });
70
+ const results = response.data;
71
+
72
+ if (options.dryRun) {
73
+ spinner.succeed(chalk.green('Sync check completed (Dry Run)!'));
74
+ console.log('');
75
+ console.log(chalk.bold('--- Dry-Run Updates Preview ---'));
76
+ const aeo = results.aeo_analysis;
77
+ console.log(`Overall Citation Rate : ${chalk.bold.green((aeo.overall_citation_rate * 100).toFixed(0) + '%')}`);
78
+ console.log(`Overall Gap Score : ${chalk.bold.red(aeo.overall_gap_score)}`);
79
+ console.log(`Last Analyzed : ${chalk.dim(aeo.last_analyzed)}`);
80
+ console.log(chalk.yellow('\n⚠ Dry-run enabled: No files were modified.'));
81
+ console.log('');
82
+ process.exit(0);
83
+ }
84
+
85
+ spinner.text = 'Updating repository files...';
86
+
87
+ // Writeback to SEO.md
88
+ await writeAnalysisToSeoMd(doc, results, cwd);
89
+
90
+ // Writeback to SEO.REVERSE.md
91
+ await writeReverseMd(cwd, results);
92
+
93
+ // Writeback to .seomd/pages/*.md
94
+ await writePageAnalysis(cwd, results);
95
+
96
+ spinner.succeed(chalk.green('Sync completed successfully!'));
97
+ console.log('');
98
+
99
+ // Display results summary
100
+ const aeo = results.aeo_analysis;
101
+ console.log(chalk.bold('--- Sync Results Summary ---'));
102
+ console.log(`Overall Citation Rate : ${chalk.bold.green((aeo.overall_citation_rate * 100).toFixed(0) + '%')}`);
103
+ console.log(`Overall Gap Score : ${chalk.bold.red(aeo.overall_gap_score)}`);
104
+
105
+ if (results.credits_remaining !== null) {
106
+ console.log(`Credits Remaining : ${chalk.cyan(results.credits_remaining)}`);
107
+ }
108
+ console.log(`Last Analyzed : ${chalk.dim(aeo.last_analyzed)}`);
109
+ console.log(`Next Analysis Target : ${chalk.dim(aeo.next_analysis)}`);
110
+ console.log('----------------------------');
111
+ console.log('');
112
+ console.log(chalk.green('āœ” SEO.md updated.'));
113
+ console.log(chalk.green('āœ” SEO.REVERSE.md updated.'));
114
+ console.log(chalk.green('āœ” .seomd/pages/ playbooks synchronized.'));
115
+ console.log('');
116
+
117
+ } catch (err) {
118
+ spinner.fail(chalk.red('Sync failed'));
119
+ console.error(chalk.bold.red(`\nError: ${err.message}`));
120
+ process.exit(1);
121
+ }
122
+ }
@@ -0,0 +1,58 @@
1
+ import chalk from 'chalk';
2
+ import { parseSeoMd } from '../utils/parser.js';
3
+ import { validateSeoMd } from '../validators/seomd.js';
4
+
5
+ export async function validateCommand() {
6
+ console.log('');
7
+ console.log(chalk.bold('Validating SEO.md...') + '\n');
8
+
9
+ try {
10
+ const { data } = await parseSeoMd(process.cwd());
11
+ const { errors, warnings } = validateSeoMd(data);
12
+
13
+ if (errors.length === 0 && warnings.length === 0) {
14
+ console.log(chalk.green(' āœ“ ') + chalk.bold('SEO.md is fully compliant with spec v1.0!'));
15
+ console.log('');
16
+ process.exit(0);
17
+ }
18
+
19
+ // Print errors
20
+ if (errors.length > 0) {
21
+ console.log(chalk.red.bold(` Errors (${errors.length}):`));
22
+ errors.forEach(err => {
23
+ const pathStr = err.path ? chalk.dim(`[${err.path}]`) : '';
24
+ console.log(` ${chalk.red('āœ—')} ${err.message} ${pathStr}`);
25
+ });
26
+ console.log('');
27
+ }
28
+
29
+ // Print warnings
30
+ if (warnings.length > 0) {
31
+ console.log(chalk.yellow.bold(` Warnings (${warnings.length}):`));
32
+ warnings.forEach(warn => {
33
+ const pathStr = warn.path ? chalk.dim(`[${warn.path}]`) : '';
34
+ console.log(` ${chalk.yellow('⚠')} ${warn.message} ${pathStr}`);
35
+ });
36
+ console.log('');
37
+ }
38
+
39
+ // Final status report
40
+ if (errors.length > 0) {
41
+ console.log(chalk.red.bold(`āœ— Validation failed: ${errors.length} error(s) and ${warnings.length} warning(s) found.`));
42
+ console.log(chalk.dim('Please fix the errors to make your SEO.md valid.'));
43
+ console.log('');
44
+ process.exit(1);
45
+ } else {
46
+ console.log(chalk.yellow.bold(`āœ“ Validation passed with ${warnings.length} warning(s).`));
47
+ console.log(chalk.dim('Warnings are optional recommendations and do not block compliance.'));
48
+ console.log('');
49
+ process.exit(0);
50
+ }
51
+
52
+ } catch (err) {
53
+ console.log(chalk.red('āœ— ') + chalk.bold('Validation process failed:'));
54
+ console.log(` ${chalk.dim(err.message)}`);
55
+ console.log('');
56
+ process.exit(1);
57
+ }
58
+ }
@@ -0,0 +1,136 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { REQUIRED_PAGES } from '../utils/constants.js';
4
+
5
+ export async function createSeomdDir(cwd, answers) {
6
+ const { site_type, brand, domain } = answers;
7
+ const pages = REQUIRED_PAGES[site_type] || REQUIRED_PAGES.saas;
8
+ const seomdDir = path.join(cwd, '.seomd');
9
+
10
+ // Create directory structure
11
+ await fs.ensureDir(path.join(seomdDir, 'pages'));
12
+ await fs.ensureDir(path.join(seomdDir, 'reports'));
13
+ await fs.ensureDir(path.join(seomdDir, 'competitors'));
14
+
15
+ // Create README inside .seomd/
16
+ await fs.writeFile(
17
+ path.join(seomdDir, 'README.md'),
18
+ generateSeomdReadme(brand, domain),
19
+ 'utf8'
20
+ );
21
+
22
+ // Create placeholder page files
23
+ for (const page of pages) {
24
+ await fs.writeFile(
25
+ path.join(seomdDir, 'pages', `${page.id}.md`),
26
+ generatePagePlaceholder(page, brand),
27
+ 'utf8'
28
+ );
29
+ }
30
+
31
+ // Create initial empty report
32
+ const date = new Date().toISOString().split('T')[0];
33
+ await fs.writeFile(
34
+ path.join(seomdDir, 'reports', `${date}.md`),
35
+ generateInitialReport(brand, domain, date),
36
+ 'utf8'
37
+ );
38
+ }
39
+
40
+ function generateSeomdReadme(brand, domain) {
41
+ return `# .seomd/
42
+
43
+ Intelligence directory for ${brand} (${domain})
44
+ Generated by SEO.md CLI — https://seomd.dev
45
+
46
+ ## Directory Structure
47
+
48
+ \`\`\`
49
+ .seomd/
50
+ ā”œā”€ā”€ pages/ # per-page reverse engineer analysis
51
+ ā”œā”€ā”€ reports/ # dated snapshot reports (gitignored by default)
52
+ └── competitors/ # competitor citation profiles
53
+ \`\`\`
54
+
55
+ ## Usage
56
+
57
+ \`\`\`bash
58
+ # Run citation analysis and update all files
59
+ npx seomd analyze
60
+
61
+ # Sync latest platform intelligence
62
+ npx seomd sync
63
+
64
+ # View current status
65
+ npx seomd status
66
+ \`\`\`
67
+
68
+ ## File Ownership
69
+
70
+ - \`pages/\` — platform generated, do not edit manually
71
+ - \`reports/\` — platform generated, gitignored by default
72
+ - \`competitors/\` — platform generated, do not edit manually
73
+
74
+ Connect your platform at https://seomd.dev/connect
75
+ `;
76
+ }
77
+
78
+ function generatePagePlaceholder(page, brand) {
79
+ const date = new Date().toISOString().split('T')[0];
80
+ return `# ${page.id}
81
+
82
+ ## Page: ${page.url}
83
+
84
+ ### Priority: ${page.priority}
85
+
86
+ ### Last analyzed: null
87
+
88
+ ### Run \`npx seomd analyze --page ${page.url}\` to populate
89
+
90
+ brand: "${brand}"
91
+ page_id: ${page.id}
92
+ url: ${page.url}
93
+ status: planned
94
+ citation_rate: null
95
+ gap_score: null
96
+ top_cited_competitor: null
97
+ last_analyzed: null
98
+
99
+ why_page_won: []
100
+ citation_hooks: []
101
+ gaps_for_brand: []
102
+ remediation_playbook: []
103
+
104
+ fastest_win:
105
+ action: null
106
+ estimated_gap_score_improvement: null
107
+ effort: null
108
+
109
+ suggested_content_outline: []
110
+ `;
111
+ }
112
+
113
+ function generateInitialReport(brand, domain, date) {
114
+ return `# SEO.md Report — ${date}
115
+
116
+ ## ${brand} (${domain})
117
+
118
+ ### Initial report — run \`npx seomd analyze\` to populate
119
+
120
+ date: ${date}
121
+ brand: "${brand}"
122
+ domain: ${domain}
123
+ overall_citation_rate: null
124
+ overall_gap_score: null
125
+
126
+ intent_summary:
127
+ informational: null
128
+ comparison: null
129
+ transactional: null
130
+ reputational: null
131
+ category: null
132
+
133
+ top_opportunities: []
134
+ completed_wins: []
135
+ `;
136
+ }
@@ -0,0 +1,62 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ /**
8
+ * Generates the content of the seo.reverse.md file by reading the corresponding site type template
9
+ * and substituting placeholders.
10
+ *
11
+ * @param {any} answers - Scaffolding inputs (brand, domain, primary_keyword, site_type, competitors)
12
+ * @returns {string} The filled template content
13
+ */
14
+ export function generateReverseMd(answers) {
15
+ const { site_type, domain, brand, competitors } = answers;
16
+ const competitorList = Array.isArray(competitors) ? competitors : [];
17
+
18
+ // Resolve template file path
19
+ const templatePath = path.join(__dirname, '../templates', site_type, 'SEO.REVERSE.md');
20
+
21
+ if (!fs.existsSync(templatePath)) {
22
+ throw new Error(`Template not found for site type: ${site_type}`);
23
+ }
24
+
25
+ let content = fs.readFileSync(templatePath, 'utf8');
26
+
27
+ const date = new Date().toISOString().split('T')[0];
28
+ const brandLower = brand.toLowerCase();
29
+ const brandSnake = brandLower.replace(/\s+/g, '_');
30
+ const primaryCompetitor = competitorList[0] || '[competitor]';
31
+
32
+ // Formatting YAML blocks for competitors
33
+ let reverseCompetitors = '';
34
+ if (competitorList.length > 0) {
35
+ reverseCompetitors = competitorList.map(c => ` - domain: ${c}
36
+ overall_citation_rate: null
37
+ strongest_intent_category: null
38
+ weakest_intent_category: null
39
+ top_cited_pages: []
40
+ citation_patterns: []
41
+ last_analyzed: null`).join('\n\n');
42
+ } else {
43
+ reverseCompetitors = ` - domain: [competitor]
44
+ overall_citation_rate: null
45
+ strongest_intent_category: null
46
+ weakest_intent_category: null
47
+ top_cited_pages: []
48
+ citation_patterns: []
49
+ last_analyzed: null`;
50
+ }
51
+
52
+ // Perform simple string replacements
53
+ content = content
54
+ .replaceAll('{{brand}}', brand)
55
+ .replaceAll('{{brand_lower_snake}}', brandSnake)
56
+ .replaceAll('{{domain}}', domain)
57
+ .replaceAll('{{primary_competitor}}', primaryCompetitor)
58
+ .replaceAll('{{date}}', date)
59
+ .replaceAll('{{reverse_competitors}}', reverseCompetitors);
60
+
61
+ return content;
62
+ }
@@ -0,0 +1,55 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ function getBrandName(domain) {
8
+ return domain
9
+ .replace(/^(https?:\/\/)?(www\.)?/, '')
10
+ .replace(/\.[a-z]{2,6}(\.[a-z]{2,6})?$/i, '');
11
+ }
12
+
13
+ /**
14
+ * Generates the content of the seo.md file by reading the corresponding site type template
15
+ * and substituting placeholders.
16
+ *
17
+ * @param {any} answers - Scaffolding inputs (brand, domain, primary_keyword, site_type, competitors)
18
+ * @returns {string} The filled template content
19
+ */
20
+ export function generateSeoMd(answers) {
21
+ const { site_type, domain, brand, primary_keyword, competitors } = answers;
22
+ const competitorList = Array.isArray(competitors) ? competitors : [];
23
+
24
+ // Resolve template file path
25
+ const templatePath = path.join(__dirname, '../templates', site_type, 'SEO.md');
26
+
27
+ if (!fs.existsSync(templatePath)) {
28
+ throw new Error(`Template not found for site type: ${site_type}`);
29
+ }
30
+
31
+ let content = fs.readFileSync(templatePath, 'utf8');
32
+
33
+ const date = new Date().toISOString().split('T')[0];
34
+ const brandLower = brand.toLowerCase();
35
+ const brandSnake = brandLower.replace(/\s+/g, '_');
36
+
37
+ // Formatting YAML blocks for competitors
38
+ const competitorTerms = competitorList.map(c => ` - "${getBrandName(c)} alternative"`).join('\n') || ' []';
39
+ const comparisonQueries = competitorList.map(c => ` - "${getBrandName(c)} vs ${brandLower}"`).join('\n') || ` - "${brandLower} vs [competitor]"`;
40
+ const toMonitor = competitorList.map(c => ` - ${c}`).join('\n') || ' []';
41
+
42
+ // Perform simple string replacements
43
+ content = content
44
+ .replaceAll('{{brand}}', brand)
45
+ .replaceAll('{{brand_lower}}', brandLower)
46
+ .replaceAll('{{brand_lower_snake}}', brandSnake)
47
+ .replaceAll('{{domain}}', domain)
48
+ .replaceAll('{{primary_keyword}}', primary_keyword)
49
+ .replaceAll('{{date}}', date)
50
+ .replaceAll('{{competitor_terms}}', competitorTerms)
51
+ .replaceAll('{{competitors_comparison_queries}}', comparisonQueries)
52
+ .replaceAll('{{competitors_to_monitor}}', toMonitor);
53
+
54
+ return content;
55
+ }
@@ -0,0 +1,176 @@
1
+ # SEO.REVERSE.md
2
+
3
+ ## {{brand}}
4
+
5
+ ### spec v1.0 | https://seomd.dev
6
+
7
+ #### generated: {{date}}
8
+
9
+ ## This file is generated by the {{brand}} platform.
10
+ ### Do not edit manually — run `npx seomd analyze` to update.
11
+ ### Run `npx seomd sync` to pull latest intelligence.
12
+
13
+ ## Each page section contains a full reverse engineer analysis:
14
+
15
+ ### why_page_won — what the top cited competitor does right
16
+ ### citation_hooks — specific patterns to replicate
17
+ ### gaps_for_brand — where your page falls short
18
+ ### remediation_playbook — step by step fixes with effort/impact
19
+ ### fastest_win — highest ROI action to take now
20
+ ### suggested_content_outline — ready to use outline for your writer/AI
21
+
22
+ domain: {{domain}}
23
+ brand: "{{brand}}"
24
+ primary_competitor: {{primary_competitor}}
25
+ last_analyzed: null
26
+ next_analysis: null
27
+ source: null
28
+
29
+ ## Pages
30
+ ### Run `npx seomd analyze` to populate these sections
31
+
32
+ pages:
33
+
34
+ ### Homepage
35
+
36
+ - id: homepage
37
+ url: /
38
+ status: planned
39
+ citation_rate: null
40
+ gap_score: null
41
+ top_cited_competitor: null
42
+
43
+ why_page_won: []
44
+ citation_hooks: []
45
+
46
+ gaps_for_{{brand_lower_snake}}: []
47
+
48
+ remediation_playbook: []
49
+
50
+ fastest_win:
51
+ action: null
52
+ estimated_gap_score_improvement: null
53
+ effort: null
54
+
55
+ suggested_content_outline: []
56
+
57
+ ### Category
58
+
59
+ - id: category
60
+ url: /[category]
61
+ status: planned
62
+ citation_rate: null
63
+ gap_score: null
64
+ top_cited_competitor: null
65
+
66
+ why_page_won: []
67
+ citation_hooks: []
68
+
69
+ gaps_for_{{brand_lower_snake}}: []
70
+
71
+ remediation_playbook: []
72
+
73
+ fastest_win:
74
+ action: null
75
+ estimated_gap_score_improvement: null
76
+ effort: null
77
+
78
+ suggested_content_outline: []
79
+
80
+ ### Article
81
+
82
+ - id: article
83
+ url: /[category]/[slug]
84
+ status: planned
85
+ citation_rate: null
86
+ gap_score: null
87
+ top_cited_competitor: null
88
+
89
+ why_page_won: []
90
+ citation_hooks: []
91
+
92
+ gaps_for_{{brand_lower_snake}}: []
93
+
94
+ remediation_playbook: []
95
+
96
+ fastest_win:
97
+ action: null
98
+ estimated_gap_score_improvement: null
99
+ effort: null
100
+
101
+ suggested_content_outline: []
102
+
103
+ ### Author
104
+
105
+ - id: author
106
+ url: /author/[slug]
107
+ status: planned
108
+ citation_rate: null
109
+ gap_score: null
110
+ top_cited_competitor: null
111
+
112
+ why_page_won: []
113
+ citation_hooks: []
114
+
115
+ gaps_for_{{brand_lower_snake}}: []
116
+
117
+ remediation_playbook: []
118
+
119
+ fastest_win:
120
+ action: null
121
+ estimated_gap_score_improvement: null
122
+ effort: null
123
+
124
+ suggested_content_outline: []
125
+
126
+ ### About
127
+
128
+ - id: about
129
+ url: /about
130
+ status: planned
131
+ citation_rate: null
132
+ gap_score: null
133
+ top_cited_competitor: null
134
+
135
+ why_page_won: []
136
+ citation_hooks: []
137
+
138
+ gaps_for_{{brand_lower_snake}}: []
139
+
140
+ remediation_playbook: []
141
+
142
+ fastest_win:
143
+ action: null
144
+ estimated_gap_score_improvement: null
145
+ effort: null
146
+
147
+ suggested_content_outline: []
148
+
149
+ ### Newsletter
150
+
151
+ - id: newsletter
152
+ url: /newsletter
153
+ status: planned
154
+ citation_rate: null
155
+ gap_score: null
156
+ top_cited_competitor: null
157
+
158
+ why_page_won: []
159
+ citation_hooks: []
160
+
161
+ gaps_for_{{brand_lower_snake}}: []
162
+
163
+ remediation_playbook: []
164
+
165
+ fastest_win:
166
+ action: null
167
+ estimated_gap_score_improvement: null
168
+ effort: null
169
+
170
+ suggested_content_outline: []
171
+
172
+ ## Competitor Profiles
173
+ ### Platform populated — competitor citation patterns
174
+
175
+ competitors:
176
+ {{reverse_competitors}}