seomd-cli 1.1.1 → 1.2.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/README.md +184 -25
- package/bin/seomd.js +37 -0
- package/package.json +13 -8
- package/scripts/release-contributors.js +196 -0
- package/scripts/release-notes.js +194 -0
- package/src/commands/analyze.js +0 -1
- package/src/commands/init.js +35 -13
- package/src/commands/status.js +6 -3
- package/src/commands/sync.js +1 -2
- package/src/commands/validate.js +8 -4
- package/src/generators/directory.js +62 -11
- package/src/utils/constants.js +23 -8
package/README.md
CHANGED
|
@@ -1,69 +1,225 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SEO.md CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="left" style="padding: 100px 0;">
|
|
4
|
+
<img alt="SEO.md CLI logo" src="./assets/logo.svg" />
|
|
5
|
+
</p>
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
The official CLI for the [SEO.md](https://seomd.dev) open standard — AEO (AI Engine Optimization) infrastructure for technical founders.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
Use it to scaffold, validate, analyze, and sync `SEO.md` files directly from your repo.
|
|
8
10
|
|
|
9
|
-
##
|
|
11
|
+
## Table of Contents
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
- [Why SEO.md](#why-seomd)
|
|
14
|
+
- [Install](#install)
|
|
15
|
+
- [Quick Start](#quick-start)
|
|
16
|
+
- [Configuration](#configuration)
|
|
17
|
+
- [Commands](#commands)
|
|
18
|
+
- [Local Development](#local-development)
|
|
19
|
+
- [Testing](#testing)
|
|
20
|
+
- [Release Notes (Contributor Tagging)](#release-notes-contributor-tagging)
|
|
21
|
+
- [Security](#security)
|
|
22
|
+
- [Specification Reference](#specification-reference)
|
|
23
|
+
- [License](#license)
|
|
24
|
+
|
|
25
|
+
## Why SEO.md
|
|
26
|
+
|
|
27
|
+
SEO.md is a structured, version-controlled specification for describing your site, intent queries, and pages so AI engines can cite you more often.
|
|
28
|
+
|
|
29
|
+
- Declare what matters (site, identity, keywords, intent, pages)
|
|
30
|
+
- Run audits via your connected platform
|
|
31
|
+
- Write back `_analysis` blocks and per-page playbooks into your repo
|
|
32
|
+
|
|
33
|
+
## Install
|
|
12
34
|
|
|
13
35
|
```bash
|
|
14
36
|
npm install -g seomd-cli
|
|
15
37
|
```
|
|
16
38
|
|
|
17
|
-
|
|
39
|
+
Or run without installing (zero-install):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx seomd-cli init
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Verify:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
seomd --help
|
|
49
|
+
```
|
|
18
50
|
|
|
19
51
|
## Quick Start
|
|
20
52
|
|
|
21
|
-
### 1
|
|
22
|
-
|
|
53
|
+
### 1) Initialize
|
|
54
|
+
|
|
55
|
+
Run in the root of your project:
|
|
23
56
|
|
|
24
57
|
```bash
|
|
25
58
|
seomd init
|
|
26
59
|
```
|
|
27
|
-
The CLI will ask you five quick questions (e.g., brand name, domain, site type, primary keyword, and competitors) and scaffold the format matching your site type (SaaS, Blog, eCommerce, Marketplace, or Local).
|
|
28
60
|
|
|
29
|
-
|
|
30
|
-
|
|
61
|
+
Or non-interactively with flags:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
seomd init -y --type local
|
|
65
|
+
# or
|
|
66
|
+
seomd init --brand "Acme" --domain acme.com --primary-keyword "local seo"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 2) Validate
|
|
31
70
|
|
|
32
71
|
```bash
|
|
33
72
|
seomd validate
|
|
34
73
|
```
|
|
35
74
|
|
|
36
|
-
### 3
|
|
37
|
-
|
|
75
|
+
### 3) Run an Audit (Analyze) and Sync
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
seomd analyze
|
|
79
|
+
seomd sync
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 4) View Status
|
|
38
83
|
|
|
39
84
|
```bash
|
|
40
85
|
seomd status
|
|
86
|
+
seomd status --json
|
|
41
87
|
```
|
|
42
88
|
|
|
43
|
-
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
Copy `.env.example` from your platform provider docs, or create one with the vars you need:
|
|
92
|
+
|
|
93
|
+
Required for live audits:
|
|
94
|
+
```bash
|
|
95
|
+
SEOMD_API_URL=
|
|
96
|
+
SEOMD_API_KEY=your_key_here
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Optional:
|
|
100
|
+
```bash
|
|
101
|
+
SEOMD_PAYMENT_TOKEN= # x402 pay-per-scan token
|
|
102
|
+
SEOMD_DOMAIN= # override domain header
|
|
103
|
+
```
|
|
44
104
|
|
|
45
|
-
|
|
105
|
+
> If you don't have a platform key yet, `seomd init` still works without `.env`.
|
|
106
|
+
|
|
107
|
+
## Commands
|
|
46
108
|
|
|
47
109
|
### `seomd init`
|
|
48
|
-
|
|
110
|
+
|
|
111
|
+
Scaffolds `SEO.md`, `SEO.REVERSE.md`, and the `.seomd/` intelligence directory.
|
|
112
|
+
|
|
113
|
+
**Usage:**
|
|
114
|
+
```bash
|
|
115
|
+
seomd init # interactive 5-question flow
|
|
116
|
+
seomd init -y --type local # skip prompts, use defaults
|
|
117
|
+
seomd init --brand "Acme" --domain acme.com # non-interactive with partial flags
|
|
118
|
+
seomd init --type saas --brand "MyApp" --domain myapp.com --primary-keyword "billing automation" --output ./new-project
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Options:**
|
|
122
|
+
| Flag | Description |
|
|
123
|
+
|------|-------------|
|
|
124
|
+
| `-y, --yes` | skip prompts, use defaults |
|
|
125
|
+
| `--type <type>` | site type: saas, ecommerce, local, blog, marketplace |
|
|
126
|
+
| `--brand <name>` | brand name |
|
|
127
|
+
| `--domain <domain>` | primary domain |
|
|
128
|
+
| `--primary-keyword <keyword>` | primary keyword |
|
|
129
|
+
| `--competitors <list>` | comma-separated competitor list |
|
|
130
|
+
| `--output <dir>` | scaffold into a new (empty) directory instead of cwd |
|
|
131
|
+
|
|
132
|
+
**Behavior:**
|
|
133
|
+
- Interactive flow by default (5 questions)
|
|
134
|
+
- Non-interactive when `-y` is set **OR** any config flag (`--brand`, `--domain`, `--primary-keyword`, `--competitors`) is provided
|
|
135
|
+
- `--type` alone pre-selects site type in the interactive flow
|
|
136
|
+
- `--output` writes all files to the target directory (must be empty or non-existent)
|
|
49
137
|
|
|
50
138
|
### `seomd validate`
|
|
51
|
-
|
|
139
|
+
|
|
140
|
+
Validates your `SEO.md` against the spec requirements.
|
|
52
141
|
|
|
53
142
|
### `seomd status`
|
|
54
|
-
|
|
143
|
+
|
|
144
|
+
Shows current citation rates and gap scores from `_analysis`.
|
|
145
|
+
|
|
146
|
+
- `--json` outputs machine-readable JSON for scripts/CI
|
|
55
147
|
|
|
56
148
|
### `seomd analyze`
|
|
57
|
-
|
|
149
|
+
|
|
150
|
+
Runs an AI search audit via your connected platform and writes results back into:
|
|
151
|
+
|
|
152
|
+
- `SEO.md` (`_analysis` blocks)
|
|
153
|
+
- `SEO.REVERSE.md` (generated reverse view)
|
|
154
|
+
- `.seomd/pages/*.md` (per-page playbooks when available)
|
|
58
155
|
|
|
59
156
|
### `seomd sync`
|
|
60
|
-
Synchronizes live engine analysis blocks, keyword gap scores, and cited sources from your connected writeback platform.
|
|
61
157
|
|
|
62
|
-
|
|
158
|
+
Pulls cached/latest platform intelligence and writes it back to the same files as `analyze`.
|
|
159
|
+
|
|
160
|
+
- `--dry-run` prints a preview and does not modify files
|
|
161
|
+
|
|
162
|
+
## Built-in Templates
|
|
163
|
+
|
|
164
|
+
`seomd-cli` ships with type-specific templates under `src/templates/`. `seomd init --type <type>` uses the matching template automatically.
|
|
165
|
+
|
|
166
|
+
| Type | Template dir | Best for |
|
|
167
|
+
|------|-------------|----------|
|
|
168
|
+
| `saas` | `src/templates/saas/` | Software products, B2B tools, web apps |
|
|
169
|
+
| `blog` | `src/templates/blog/` | Content sites, newsletters, personal brands |
|
|
170
|
+
| `ecommerce` | `src/templates/ecommerce/` | Online stores, DTC brands, product catalogs |
|
|
171
|
+
| `local` | `src/templates/local/` | Service-area businesses, locations pages |
|
|
172
|
+
| `marketplace` | `src/templates/marketplace/` | Two-sided platforms, directories |
|
|
173
|
+
|
|
174
|
+
Each template contains:
|
|
175
|
+
|
|
176
|
+
- `SEO.md` — pre-filled with type-specific intent queries, page structures, and negative keywords
|
|
177
|
+
- `SEO.REVERSE.md` — reverse-engineer output scaffold with placeholders for competitor analysis
|
|
178
|
+
|
|
179
|
+
Want to customize a template? Copy the relevant folder, edit the placeholders (`{{brand}}`, `{{domain}}`, etc.), and use `--type` with your custom scaffold.
|
|
180
|
+
|
|
181
|
+
## Local Development
|
|
182
|
+
|
|
183
|
+
Prefer the local entrypoint while developing:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
node ./bin/seomd.js --help
|
|
187
|
+
node ./bin/seomd.js init
|
|
188
|
+
node ./bin/seomd.js validate
|
|
189
|
+
node ./bin/seomd.js status --json
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Testing
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
npm test
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Release Notes (Contributor Tagging)
|
|
199
|
+
|
|
200
|
+
To generate a contributor section for a release (commit-based attribution), maintain mappings in `.github/contributors.yml` and generate markdown from a tag range:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
npm run release:contributors -- --from v1.0.2 --to v1.0.3
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
To write output to a file:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
npm run release:contributors -- --from v1.0.2 --to v1.0.3 --out notes/v1.0.3-contributors.md
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
To generate a full release note (changes + contributors) for a tag:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
npm run release:notes -- --tag v1.0.3
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Automation: the repository includes a GitHub Actions workflow that runs on tag push (`v*`) and creates/updates the GitHub Release using `scripts/release-notes.js`.
|
|
63
219
|
|
|
64
220
|
## Platform Connections & API Keys
|
|
65
221
|
|
|
66
|
-
To enable live
|
|
222
|
+
To enable live intelligence writebacks (using automated platforms like [Foxcite](https://foxcite.com)):
|
|
67
223
|
|
|
68
224
|
1. Obtain a developer API key from your platform provider.
|
|
69
225
|
2. Export the key as an environment variable:
|
|
@@ -72,9 +228,12 @@ To enable live citation writebacks (using automated platforms like [Foxcite](htt
|
|
|
72
228
|
```
|
|
73
229
|
3. Run `seomd sync` or `seomd analyze`.
|
|
74
230
|
|
|
75
|
-
|
|
231
|
+
_Note: Never commit your API keys or `.env` files containing keys to version control._
|
|
232
|
+
|
|
233
|
+
## Security
|
|
76
234
|
|
|
77
|
-
|
|
235
|
+
- Never commit `.env` files or API keys
|
|
236
|
+
- Use `.env.example` as the template for required variables
|
|
78
237
|
|
|
79
238
|
## Specification Reference
|
|
80
239
|
|
package/bin/seomd.js
CHANGED
|
@@ -25,6 +25,19 @@ program
|
|
|
25
25
|
.description('Scaffold SEO.md for your project')
|
|
26
26
|
.option('-y, --yes', 'skip prompts and use defaults')
|
|
27
27
|
.option('--type <type>', 'site type: saas, ecommerce, local, blog, marketplace')
|
|
28
|
+
.option('--brand <brand>', 'brand name')
|
|
29
|
+
.option('--domain <domain>', 'primary domain')
|
|
30
|
+
.option('--primary-keyword <keyword>', 'primary keyword')
|
|
31
|
+
.option('--competitors <list>', 'comma-separated competitor list')
|
|
32
|
+
.option('--output <dir>', 'scaffold into a new directory instead of cwd')
|
|
33
|
+
.addHelpText('after', `
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
seomd init # interactive 5-question flow
|
|
37
|
+
seomd init -y --type local # skip prompts, use defaults
|
|
38
|
+
seomd init --brand "Acme" --domain acme.com --primary-keyword "local seo"
|
|
39
|
+
seomd init --type saas --brand "MyApp" --domain myapp.com --output ./new-project
|
|
40
|
+
`)
|
|
28
41
|
.action(initCommand);
|
|
29
42
|
|
|
30
43
|
program
|
|
@@ -33,23 +46,47 @@ program
|
|
|
33
46
|
.option('--page <url>', 'analyze a specific page only')
|
|
34
47
|
.option('--intent <category>', 'analyze a specific intent category only')
|
|
35
48
|
.option('--engines <list>', 'comma-separated list of engines to scan (e.g. chatgpt,claude)')
|
|
49
|
+
.addHelpText('after', `
|
|
50
|
+
|
|
51
|
+
Examples:
|
|
52
|
+
seomd analyze # full audit
|
|
53
|
+
seomd analyze --page /pricing # single page audit
|
|
54
|
+
seomd analyze --intent transactional --engines chatgpt,claude
|
|
55
|
+
`)
|
|
36
56
|
.action(analyzeCommand);
|
|
37
57
|
|
|
38
58
|
program
|
|
39
59
|
.command('sync')
|
|
40
60
|
.description('Sync latest platform intelligence to your SEO.md files')
|
|
41
61
|
.option('--dry-run', 'preview changes without writing')
|
|
62
|
+
.addHelpText('after', `
|
|
63
|
+
|
|
64
|
+
Examples:
|
|
65
|
+
seomd sync # sync latest data
|
|
66
|
+
seomd sync --dry-run # preview changes only
|
|
67
|
+
`)
|
|
42
68
|
.action(syncCommand);
|
|
43
69
|
|
|
44
70
|
program
|
|
45
71
|
.command('status')
|
|
46
72
|
.description('Show current citation rates and gap scores')
|
|
47
73
|
.option('--json', 'output as JSON')
|
|
74
|
+
.addHelpText('after', `
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
seomd status # human-readable summary
|
|
78
|
+
seomd status --json # machine-readable JSON
|
|
79
|
+
`)
|
|
48
80
|
.action(statusCommand);
|
|
49
81
|
|
|
50
82
|
program
|
|
51
83
|
.command('validate')
|
|
52
84
|
.description('Validate your SEO.md against the spec')
|
|
85
|
+
.addHelpText('after', `
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
seomd validate
|
|
89
|
+
`)
|
|
53
90
|
.action(validateCommand);
|
|
54
91
|
|
|
55
92
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seomd-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "The official CLI for the SEO.md open standard — AEO infrastructure for technical founders",
|
|
5
5
|
"homepage": "https://seomd.dev",
|
|
6
6
|
"repository": {
|
|
@@ -14,26 +14,31 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"bin",
|
|
17
|
+
"scripts",
|
|
17
18
|
"src"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
20
21
|
"dev": "node bin/seomd.js",
|
|
21
22
|
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
|
22
|
-
"lint": "eslint src/**/*.js"
|
|
23
|
+
"lint": "eslint src/**/*.js",
|
|
24
|
+
"release:contributors": "node scripts/release-contributors.js",
|
|
25
|
+
"release:notes": "node scripts/release-notes.js"
|
|
23
26
|
},
|
|
24
27
|
"dependencies": {
|
|
28
|
+
"axios": "^1.6.0",
|
|
25
29
|
"chalk": "^5.3.0",
|
|
26
30
|
"commander": "^11.0.0",
|
|
31
|
+
"dotenv": "^16.3.1",
|
|
27
32
|
"enquirer": "^2.4.1",
|
|
28
|
-
"ora": "^7.0.1",
|
|
29
|
-
"yaml": "^2.3.4",
|
|
30
33
|
"fs-extra": "^11.1.1",
|
|
31
|
-
"
|
|
32
|
-
"
|
|
34
|
+
"ora": "^7.0.1",
|
|
35
|
+
"yaml": "^2.3.4"
|
|
33
36
|
},
|
|
34
37
|
"devDependencies": {
|
|
35
|
-
"
|
|
36
|
-
"eslint": "^8.50.0"
|
|
38
|
+
"@eslint/js": "^10.0.1",
|
|
39
|
+
"eslint": "^8.50.0",
|
|
40
|
+
"eslint-plugin-n": "^18.1.0",
|
|
41
|
+
"jest": "^29.7.0"
|
|
37
42
|
},
|
|
38
43
|
"engines": {
|
|
39
44
|
"node": ">=18.0.0"
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
|
|
6
|
+
function parseArgs(argv) {
|
|
7
|
+
const out = { from: null, to: null, outFile: null };
|
|
8
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
9
|
+
const arg = argv[i];
|
|
10
|
+
const next = argv[i + 1];
|
|
11
|
+
if (arg === '--from' && next) {
|
|
12
|
+
out.from = next;
|
|
13
|
+
i += 1;
|
|
14
|
+
} else if (arg === '--to' && next) {
|
|
15
|
+
out.to = next;
|
|
16
|
+
i += 1;
|
|
17
|
+
} else if (arg === '--out' && next) {
|
|
18
|
+
out.outFile = next;
|
|
19
|
+
i += 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readContributorsMap(repoRoot) {
|
|
26
|
+
const filePath = path.join(repoRoot, '.github', 'contributors.yml');
|
|
27
|
+
if (!fs.existsSync(filePath)) {
|
|
28
|
+
return { version: 1, contributors: [] };
|
|
29
|
+
}
|
|
30
|
+
const parsed = YAML.parse(fs.readFileSync(filePath, 'utf8')) || {};
|
|
31
|
+
return {
|
|
32
|
+
version: parsed.version || 1,
|
|
33
|
+
contributors: Array.isArray(parsed.contributors) ? parsed.contributors : []
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeEmail(email) {
|
|
38
|
+
return String(email || '').trim().toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeName(name) {
|
|
42
|
+
return String(name || '').trim().toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveHandle(map, { name, email }) {
|
|
46
|
+
const nEmail = normalizeEmail(email);
|
|
47
|
+
const nName = normalizeName(name);
|
|
48
|
+
|
|
49
|
+
for (const c of map.contributors) {
|
|
50
|
+
if (!c || typeof c !== 'object') continue;
|
|
51
|
+
if (!c.github) continue;
|
|
52
|
+
const emails = Array.isArray(c.emails) ? c.emails.map(normalizeEmail) : [];
|
|
53
|
+
if (nEmail && emails.includes(nEmail)) return c.github;
|
|
54
|
+
const names = Array.isArray(c.names) ? c.names.map(normalizeName) : [];
|
|
55
|
+
if (nName && names.includes(nName)) return c.github;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function git(repoRoot, args) {
|
|
61
|
+
return execSync(`git ${args}`, { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getRepoRoot() {
|
|
65
|
+
return execSync('git rev-parse --show-toplevel', { stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8').trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getCommitBodies(repoRoot, range) {
|
|
69
|
+
const sepCommit = '\u001e';
|
|
70
|
+
const sepField = '\u001f';
|
|
71
|
+
const out = git(repoRoot, `log ${range} --no-color --format=%H${sepField}%an${sepField}%ae${sepField}%B${sepCommit}`);
|
|
72
|
+
const rawCommits = out.split(sepCommit).map(s => s.trim()).filter(Boolean);
|
|
73
|
+
return rawCommits.map(entry => {
|
|
74
|
+
const parts = entry.split(sepField);
|
|
75
|
+
const [sha, authorName, authorEmail] = parts;
|
|
76
|
+
const body = parts.slice(3).join(sepField);
|
|
77
|
+
return { sha, authorName, authorEmail, body };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function extractCoAuthors(commitBody) {
|
|
82
|
+
const out = [];
|
|
83
|
+
const lines = String(commitBody || '').split('\n');
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
const m = line.match(/^Co-authored-by:\s*(.+?)\s*<(.+?)>\s*$/i);
|
|
86
|
+
if (m) out.push({ name: m[1], email: m[2] });
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function bumpCounter(map, key, display) {
|
|
92
|
+
const existing = map.get(key);
|
|
93
|
+
if (existing) {
|
|
94
|
+
existing.count += 1;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
map.set(key, { count: 1, display });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function fmtHandle(handle) {
|
|
101
|
+
if (!handle) return null;
|
|
102
|
+
return handle.startsWith('@') ? handle : `@${handle}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildContribMarkdown({ from, to, resolvedCounts, unresolvedCounts }) {
|
|
106
|
+
const lines = [];
|
|
107
|
+
const rangeLabel = from && to ? `${from}..${to}` : 'range';
|
|
108
|
+
|
|
109
|
+
lines.push(`## Contributors`);
|
|
110
|
+
lines.push('');
|
|
111
|
+
lines.push(`Commit range: ${rangeLabel}`);
|
|
112
|
+
lines.push('');
|
|
113
|
+
|
|
114
|
+
const resolved = Array.from(resolvedCounts.entries())
|
|
115
|
+
.sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0]));
|
|
116
|
+
|
|
117
|
+
if (resolved.length > 0) {
|
|
118
|
+
for (const [handle, info] of resolved) {
|
|
119
|
+
lines.push(`- ${fmtHandle(handle)} (${info.count})`);
|
|
120
|
+
}
|
|
121
|
+
lines.push('');
|
|
122
|
+
} else {
|
|
123
|
+
lines.push(`- None`);
|
|
124
|
+
lines.push('');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const unresolved = Array.from(unresolvedCounts.entries())
|
|
128
|
+
.sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0]));
|
|
129
|
+
|
|
130
|
+
if (unresolved.length > 0) {
|
|
131
|
+
lines.push(`## Unmapped Contributors`);
|
|
132
|
+
lines.push('');
|
|
133
|
+
for (const [key, info] of unresolved) {
|
|
134
|
+
lines.push(`- ${info.display} (${info.count})`);
|
|
135
|
+
}
|
|
136
|
+
lines.push('');
|
|
137
|
+
lines.push(`To tag these contributors, add their name/email to .github/contributors.yml.`);
|
|
138
|
+
lines.push('');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return lines.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function main() {
|
|
145
|
+
const args = parseArgs(process.argv);
|
|
146
|
+
if (!args.from || !args.to) {
|
|
147
|
+
process.stderr.write('Usage: node scripts/release-contributors.js --from <tag> --to <tag> [--out <file>]\n');
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const repoRoot = getRepoRoot();
|
|
152
|
+
const contribMap = readContributorsMap(repoRoot);
|
|
153
|
+
const range = `${args.from}..${args.to}`;
|
|
154
|
+
const commits = getCommitBodies(repoRoot, range);
|
|
155
|
+
|
|
156
|
+
const resolvedCounts = new Map();
|
|
157
|
+
const unresolvedCounts = new Map();
|
|
158
|
+
|
|
159
|
+
for (const c of commits) {
|
|
160
|
+
const authorHandle = resolveHandle(contribMap, { name: c.authorName, email: c.authorEmail });
|
|
161
|
+
if (authorHandle) {
|
|
162
|
+
bumpCounter(resolvedCounts, authorHandle, fmtHandle(authorHandle));
|
|
163
|
+
} else {
|
|
164
|
+
bumpCounter(unresolvedCounts, `${normalizeName(c.authorName)}|${normalizeEmail(c.authorEmail)}`, `${c.authorName} <${c.authorEmail}>`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const co of extractCoAuthors(c.body)) {
|
|
168
|
+
const coHandle = resolveHandle(contribMap, co);
|
|
169
|
+
if (coHandle) {
|
|
170
|
+
bumpCounter(resolvedCounts, coHandle, fmtHandle(coHandle));
|
|
171
|
+
} else {
|
|
172
|
+
bumpCounter(unresolvedCounts, `${normalizeName(co.name)}|${normalizeEmail(co.email)}`, `${co.name} <${co.email}>`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const md = buildContribMarkdown({
|
|
178
|
+
from: args.from,
|
|
179
|
+
to: args.to,
|
|
180
|
+
resolvedCounts,
|
|
181
|
+
unresolvedCounts
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (args.outFile) {
|
|
185
|
+
const outPath = path.isAbsolute(args.outFile) ? args.outFile : path.join(repoRoot, args.outFile);
|
|
186
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
187
|
+
fs.writeFileSync(outPath, md, 'utf8');
|
|
188
|
+
process.stdout.write(`${outPath}\n`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
process.stdout.write(md + '\n');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
main();
|
|
196
|
+
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
|
|
6
|
+
function parseArgs(argv) {
|
|
7
|
+
const out = { tag: null, prev: null, outFile: null };
|
|
8
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
9
|
+
const arg = argv[i];
|
|
10
|
+
const next = argv[i + 1];
|
|
11
|
+
if (arg === '--tag' && next) {
|
|
12
|
+
out.tag = next;
|
|
13
|
+
i += 1;
|
|
14
|
+
} else if (arg === '--prev' && next) {
|
|
15
|
+
out.prev = next;
|
|
16
|
+
i += 1;
|
|
17
|
+
} else if (arg === '--out' && next) {
|
|
18
|
+
out.outFile = next;
|
|
19
|
+
i += 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function git(repoRoot, args) {
|
|
26
|
+
return execSync(`git ${args}`, { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getRepoRoot() {
|
|
30
|
+
return execSync('git rev-parse --show-toplevel', { stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8').trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readContributorsMap(repoRoot) {
|
|
34
|
+
const filePath = path.join(repoRoot, '.github', 'contributors.yml');
|
|
35
|
+
if (!fs.existsSync(filePath)) {
|
|
36
|
+
return { version: 1, contributors: [] };
|
|
37
|
+
}
|
|
38
|
+
const parsed = YAML.parse(fs.readFileSync(filePath, 'utf8')) || {};
|
|
39
|
+
return {
|
|
40
|
+
version: parsed.version || 1,
|
|
41
|
+
contributors: Array.isArray(parsed.contributors) ? parsed.contributors : []
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeEmail(email) {
|
|
46
|
+
return String(email || '').trim().toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeName(name) {
|
|
50
|
+
return String(name || '').trim().toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveHandle(map, { name, email }) {
|
|
54
|
+
const nEmail = normalizeEmail(email);
|
|
55
|
+
const nName = normalizeName(name);
|
|
56
|
+
|
|
57
|
+
for (const c of map.contributors) {
|
|
58
|
+
if (!c || typeof c !== 'object') continue;
|
|
59
|
+
if (!c.github) continue;
|
|
60
|
+
const emails = Array.isArray(c.emails) ? c.emails.map(normalizeEmail) : [];
|
|
61
|
+
if (nEmail && emails.includes(nEmail)) return c.github;
|
|
62
|
+
const names = Array.isArray(c.names) ? c.names.map(normalizeName) : [];
|
|
63
|
+
if (nName && names.includes(nName)) return c.github;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fmtHandle(handle) {
|
|
69
|
+
if (!handle) return null;
|
|
70
|
+
return handle.startsWith('@') ? handle : `@${handle}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function bumpCounter(map, key) {
|
|
74
|
+
map.set(key, (map.get(key) || 0) + 1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractCoAuthors(commitBody) {
|
|
78
|
+
const out = [];
|
|
79
|
+
const lines = String(commitBody || '').split('\n');
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
const m = line.match(/^Co-authored-by:\s*(.+?)\s*<(.+?)>\s*$/i);
|
|
82
|
+
if (m) out.push({ name: m[1], email: m[2] });
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getCommitBodies(repoRoot, range) {
|
|
88
|
+
const sepCommit = '\u001e';
|
|
89
|
+
const sepField = '\u001f';
|
|
90
|
+
const out = git(repoRoot, `log ${range} --no-color --format=%H${sepField}%an${sepField}%ae${sepField}%s${sepField}%B${sepCommit}`);
|
|
91
|
+
const rawCommits = out.split(sepCommit).map(s => s.trim()).filter(Boolean);
|
|
92
|
+
return rawCommits.map(entry => {
|
|
93
|
+
const parts = entry.split(sepField);
|
|
94
|
+
const [sha, authorName, authorEmail, subject] = parts;
|
|
95
|
+
const body = parts.slice(4).join(sepField);
|
|
96
|
+
return { sha, authorName, authorEmail, subject, body };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function determinePrevTag(repoRoot, tag) {
|
|
101
|
+
try {
|
|
102
|
+
return git(repoRoot, `describe --tags --abbrev=0 ${tag}^`).trim();
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildReleaseMarkdown({ tag, prev, commits, contributors }) {
|
|
109
|
+
const lines = [];
|
|
110
|
+
|
|
111
|
+
lines.push(`# ${tag}`);
|
|
112
|
+
lines.push('');
|
|
113
|
+
|
|
114
|
+
if (prev) {
|
|
115
|
+
lines.push(`Changes since ${prev}`);
|
|
116
|
+
lines.push('');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (commits.length > 0) {
|
|
120
|
+
lines.push(`## Changes`);
|
|
121
|
+
lines.push('');
|
|
122
|
+
for (const c of commits) {
|
|
123
|
+
const sha = String(c.sha || '').slice(0, 7);
|
|
124
|
+
lines.push(`- ${c.subject} (${sha})`);
|
|
125
|
+
}
|
|
126
|
+
lines.push('');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
lines.push(`## Contributors`);
|
|
130
|
+
lines.push('');
|
|
131
|
+
|
|
132
|
+
const sorted = Array.from(contributors.entries())
|
|
133
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
134
|
+
|
|
135
|
+
if (sorted.length === 0) {
|
|
136
|
+
lines.push(`- None`);
|
|
137
|
+
lines.push('');
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const [handle, count] of sorted) {
|
|
142
|
+
lines.push(`- ${fmtHandle(handle)} (${count})`);
|
|
143
|
+
}
|
|
144
|
+
lines.push('');
|
|
145
|
+
|
|
146
|
+
return lines.join('\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function main() {
|
|
150
|
+
const args = parseArgs(process.argv);
|
|
151
|
+
if (!args.tag) {
|
|
152
|
+
process.stderr.write('Usage: node scripts/release-notes.js --tag <tag> [--prev <tag>] [--out <file>]\n');
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const repoRoot = getRepoRoot();
|
|
157
|
+
const prev = args.prev || determinePrevTag(repoRoot, args.tag);
|
|
158
|
+
const range = prev ? `${prev}..${args.tag}` : args.tag;
|
|
159
|
+
|
|
160
|
+
const contribMap = readContributorsMap(repoRoot);
|
|
161
|
+
const commits = getCommitBodies(repoRoot, range);
|
|
162
|
+
|
|
163
|
+
const contributors = new Map();
|
|
164
|
+
|
|
165
|
+
for (const c of commits) {
|
|
166
|
+
const authorHandle = resolveHandle(contribMap, { name: c.authorName, email: c.authorEmail });
|
|
167
|
+
if (authorHandle) bumpCounter(contributors, authorHandle);
|
|
168
|
+
|
|
169
|
+
for (const co of extractCoAuthors(c.body)) {
|
|
170
|
+
const coHandle = resolveHandle(contribMap, co);
|
|
171
|
+
if (coHandle) bumpCounter(contributors, coHandle);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const md = buildReleaseMarkdown({
|
|
176
|
+
tag: args.tag,
|
|
177
|
+
prev,
|
|
178
|
+
commits,
|
|
179
|
+
contributors
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (args.outFile) {
|
|
183
|
+
const outPath = path.isAbsolute(args.outFile) ? args.outFile : path.join(repoRoot, args.outFile);
|
|
184
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
185
|
+
fs.writeFileSync(outPath, md, 'utf8');
|
|
186
|
+
process.stdout.write(`${outPath}\n`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
process.stdout.write(md + '\n');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
main();
|
|
194
|
+
|
package/src/commands/analyze.js
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -16,8 +16,19 @@ export async function initCommand(options) {
|
|
|
16
16
|
console.log(chalk.dim('The open standard for AI-era SEO configuration.'));
|
|
17
17
|
console.log('');
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
|
|
19
|
+
// Determine target directory
|
|
20
|
+
let workingDir = process.cwd();
|
|
21
|
+
if (options.output) {
|
|
22
|
+
const resolved = path.resolve(options.output);
|
|
23
|
+
if (await fs.pathExists(resolved) && (await fs.readdir(resolved)).length > 0) {
|
|
24
|
+
console.log(chalk.red(`Output directory must be empty or non-existent: ${resolved}`));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
await fs.ensureDir(resolved);
|
|
28
|
+
workingDir = resolved;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const seomdPath = path.join(workingDir, 'SEO.md');
|
|
21
32
|
if (await fs.pathExists(seomdPath)) {
|
|
22
33
|
console.log(chalk.yellow('⚠ SEO.md already exists in this directory.'));
|
|
23
34
|
const { overwrite } = await prompt({
|
|
@@ -34,14 +45,26 @@ export async function initCommand(options) {
|
|
|
34
45
|
|
|
35
46
|
let answers;
|
|
36
47
|
|
|
37
|
-
if
|
|
38
|
-
|
|
48
|
+
// Mode 5: non-interactive if -y OR any config field provided
|
|
49
|
+
const hasConfigFlags =
|
|
50
|
+
options.brand !== undefined ||
|
|
51
|
+
options.domain !== undefined ||
|
|
52
|
+
options.primaryKeyword !== undefined ||
|
|
53
|
+
options.competitors !== undefined;
|
|
54
|
+
|
|
55
|
+
if (options.yes || hasConfigFlags) {
|
|
39
56
|
answers = {
|
|
40
57
|
site_type: options.type || 'saas',
|
|
41
|
-
domain: 'example.com',
|
|
42
|
-
brand: 'My Brand',
|
|
43
|
-
primary_keyword: '',
|
|
44
|
-
competitors:
|
|
58
|
+
domain: options.domain || 'example.com',
|
|
59
|
+
brand: options.brand || 'My Brand',
|
|
60
|
+
primary_keyword: options.primaryKeyword || '',
|
|
61
|
+
competitors: options.competitors
|
|
62
|
+
? options.competitors
|
|
63
|
+
.split(',')
|
|
64
|
+
.map((c) => c.trim())
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.slice(0, 3)
|
|
67
|
+
: [],
|
|
45
68
|
};
|
|
46
69
|
} else {
|
|
47
70
|
// The 5-question init flow
|
|
@@ -109,21 +132,20 @@ export async function initCommand(options) {
|
|
|
109
132
|
try {
|
|
110
133
|
// 1. Generate SEO.md
|
|
111
134
|
const seomdContent = generateSeoMd(answers);
|
|
112
|
-
await fs.writeFile(
|
|
135
|
+
await fs.writeFile(path.join(workingDir, 'SEO.md'), seomdContent, 'utf8');
|
|
113
136
|
spinner.succeed(chalk.green('SEO.md created'));
|
|
114
137
|
|
|
115
138
|
// 2. Generate SEO.REVERSE.md
|
|
116
|
-
const reversePath = path.join(process.cwd(), 'SEO.REVERSE.md');
|
|
117
139
|
const reverseContent = generateReverseMd(answers);
|
|
118
|
-
await fs.writeFile(
|
|
140
|
+
await fs.writeFile(path.join(workingDir, 'SEO.REVERSE.md'), reverseContent, 'utf8');
|
|
119
141
|
spinner.succeed(chalk.green('SEO.REVERSE.md initialized'));
|
|
120
142
|
|
|
121
143
|
// 3. Create .seomd/ directory structure
|
|
122
|
-
await createSeomdDir(
|
|
144
|
+
await createSeomdDir(workingDir, answers);
|
|
123
145
|
spinner.succeed(chalk.green('.seomd/ directory created'));
|
|
124
146
|
|
|
125
147
|
// 4. Add .seomd/ to .gitignore if it exists
|
|
126
|
-
await updateGitignore(
|
|
148
|
+
await updateGitignore(workingDir);
|
|
127
149
|
|
|
128
150
|
console.log('');
|
|
129
151
|
console.log(chalk.bold.green('✓ SEO.md initialized successfully'));
|
package/src/commands/status.js
CHANGED
|
@@ -28,7 +28,8 @@ export async function statusCommand(options) {
|
|
|
28
28
|
console.log(chalk.white(' npx seomd analyze'));
|
|
29
29
|
console.log('');
|
|
30
30
|
}
|
|
31
|
-
process.
|
|
31
|
+
process.exitCode = 0;
|
|
32
|
+
return;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
// Format overall metrics
|
|
@@ -77,7 +78,8 @@ export async function statusCommand(options) {
|
|
|
77
78
|
});
|
|
78
79
|
|
|
79
80
|
console.log(JSON.stringify(output, null, 2));
|
|
80
|
-
process.
|
|
81
|
+
process.exitCode = 0;
|
|
82
|
+
return;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
// Output beautiful terminal dashboard
|
|
@@ -145,7 +147,8 @@ export async function statusCommand(options) {
|
|
|
145
147
|
console.log(chalk.red('✗ ') + chalk.bold('Failed to display status:'));
|
|
146
148
|
console.log(` ${chalk.dim(err.message)}`);
|
|
147
149
|
console.log('');
|
|
148
|
-
process.
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
return;
|
|
149
152
|
}
|
|
150
153
|
}
|
|
151
154
|
|
package/src/commands/sync.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import ora from 'ora';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
|
-
import path from 'path';
|
|
5
4
|
import dotenv from 'dotenv';
|
|
6
5
|
import { parseSeoMd } from '../utils/parser.js';
|
|
7
6
|
import { client } from '../utils/api-client.js';
|
|
@@ -79,7 +78,7 @@ export async function syncCommand(options) {
|
|
|
79
78
|
console.log(`Last Analyzed : ${chalk.dim(aeo.last_analyzed)}`);
|
|
80
79
|
console.log(chalk.yellow('\n⚠ Dry-run enabled: No files were modified.'));
|
|
81
80
|
console.log('');
|
|
82
|
-
|
|
81
|
+
return;
|
|
83
82
|
}
|
|
84
83
|
|
|
85
84
|
spinner.text = 'Updating repository files...';
|
package/src/commands/validate.js
CHANGED
|
@@ -13,7 +13,8 @@ export async function validateCommand() {
|
|
|
13
13
|
if (errors.length === 0 && warnings.length === 0) {
|
|
14
14
|
console.log(chalk.green(' ✓ ') + chalk.bold('SEO.md is fully compliant with spec v1.0!'));
|
|
15
15
|
console.log('');
|
|
16
|
-
process.
|
|
16
|
+
process.exitCode = 0;
|
|
17
|
+
return;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
// Print errors
|
|
@@ -41,18 +42,21 @@ export async function validateCommand() {
|
|
|
41
42
|
console.log(chalk.red.bold(`✗ Validation failed: ${errors.length} error(s) and ${warnings.length} warning(s) found.`));
|
|
42
43
|
console.log(chalk.dim('Please fix the errors to make your SEO.md valid.'));
|
|
43
44
|
console.log('');
|
|
44
|
-
process.
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
return;
|
|
45
47
|
} else {
|
|
46
48
|
console.log(chalk.yellow.bold(`✓ Validation passed with ${warnings.length} warning(s).`));
|
|
47
49
|
console.log(chalk.dim('Warnings are optional recommendations and do not block compliance.'));
|
|
48
50
|
console.log('');
|
|
49
|
-
process.
|
|
51
|
+
process.exitCode = 0;
|
|
52
|
+
return;
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
} catch (err) {
|
|
53
56
|
console.log(chalk.red('✗ ') + chalk.bold('Validation process failed:'));
|
|
54
57
|
console.log(` ${chalk.dim(err.message)}`);
|
|
55
58
|
console.log('');
|
|
56
|
-
process.
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
return;
|
|
57
61
|
}
|
|
58
62
|
}
|
|
@@ -40,38 +40,89 @@ export async function createSeomdDir(cwd, answers) {
|
|
|
40
40
|
function generateSeomdReadme(brand, domain) {
|
|
41
41
|
return `# .seomd/
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
AI search optimization workspace for **${brand}** (${domain})
|
|
44
44
|
Generated by SEO.md CLI — https://seomd.dev
|
|
45
45
|
|
|
46
|
+
## What is this?
|
|
47
|
+
|
|
48
|
+
\`.seomd/\` stores platform-generated intelligence from citation audits, competitor analysis, and reverse-engineered playbooks. It lives alongside your source code so SEO strategy is version-controlled and reviewable in PRs.
|
|
49
|
+
|
|
46
50
|
## Directory Structure
|
|
47
51
|
|
|
48
52
|
\`\`\`
|
|
49
53
|
.seomd/
|
|
50
|
-
├── pages/ # per-page reverse
|
|
54
|
+
├── pages/ # per-page reverse-engineer analysis
|
|
55
|
+
│ ├── homepage.md
|
|
56
|
+
│ ├── services.md
|
|
57
|
+
│ └── ...
|
|
51
58
|
├── reports/ # dated snapshot reports (gitignored by default)
|
|
59
|
+
│ └── 2026-06-10.md
|
|
52
60
|
└── competitors/ # competitor citation profiles
|
|
61
|
+
└── comp-1.md
|
|
53
62
|
\`\`\`
|
|
54
63
|
|
|
55
|
-
##
|
|
64
|
+
## Git behavior
|
|
65
|
+
|
|
66
|
+
| Path | Git | Why |
|
|
67
|
+
|------|-----|-----|
|
|
68
|
+
| \`.seomd/pages/\` | tracked | actionable playbooks belong in code review |
|
|
69
|
+
| \`.seomd/competitors/\` | tracked | competitor intel is project state |
|
|
70
|
+
| \`.seomd/reports/\` | ignored | dated snapshots are noise in git history |
|
|
71
|
+
|
|
72
|
+
\`.seomd/reports/\` is added to \`.gitignore\` automatically by \`seomd init\`.
|
|
73
|
+
|
|
74
|
+
## When to run what
|
|
56
75
|
|
|
57
76
|
\`\`\`bash
|
|
58
|
-
#
|
|
77
|
+
# First full audit — scans all pages + intents
|
|
59
78
|
npx seomd analyze
|
|
60
79
|
|
|
61
|
-
#
|
|
80
|
+
# Refresh from cached platform data (no new scan)
|
|
62
81
|
npx seomd sync
|
|
63
82
|
|
|
64
|
-
#
|
|
83
|
+
# Check current gap scores without modifying files
|
|
65
84
|
npx seomd status
|
|
85
|
+
npx seomd status --json
|
|
66
86
|
\`\`\`
|
|
67
87
|
|
|
68
|
-
|
|
88
|
+
| Command | Use when |
|
|
89
|
+
|---------|----------|
|
|
90
|
+
| \`analyze\` | You want a fresh audit, new competitor data, or updated playbooks |
|
|
91
|
+
| \`sync\` | You just want the latest cached data from your platform |
|
|
92
|
+
| \`status\` | You want a quick health check without writebacks |
|
|
93
|
+
|
|
94
|
+
## Reading a page playbook
|
|
95
|
+
|
|
96
|
+
Each \`pages/<id>.md\` contains:
|
|
97
|
+
|
|
98
|
+
- **why_page_won** — what top-cited competitors do right
|
|
99
|
+
- **citation_hooks** — patterns to replicate
|
|
100
|
+
- **gaps_for_brand** — where your page falls short
|
|
101
|
+
- **remediation_playbook** — step-by-step fixes with effort/impact
|
|
102
|
+
- **fastest_win** — highest ROI action to take now
|
|
103
|
+
- **suggested_content_outline** — ready-to-use outline for your writer or AI
|
|
104
|
+
|
|
105
|
+
## File ownership
|
|
106
|
+
|
|
107
|
+
| File/dir | Owner | Editable? |
|
|
108
|
+
|-----------|-------|----------|
|
|
109
|
+
| \`SEO.md\` | you | yes |
|
|
110
|
+
| \`SEO.REVERSE.md\` | platform | no |
|
|
111
|
+
| \`.seomd/pages/*.md\` | platform | no |
|
|
112
|
+
| \`.seomd/competitors/*.md\` | platform | no |
|
|
113
|
+
| \`.seomd/reports/*.md\` | platform | no |
|
|
114
|
+
|
|
115
|
+
Platform-generated files are overwritten on each \`analyze\` or \`sync\`.
|
|
116
|
+
|
|
117
|
+
## Platform connection
|
|
118
|
+
|
|
119
|
+
To enable live writebacks:
|
|
69
120
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
121
|
+
1. Connect at https://seomd.dev/connect
|
|
122
|
+
2. Add \`SEOMD_API_KEY\` to your \`.env\`
|
|
123
|
+
3. Run \`npx seomd analyze\`
|
|
73
124
|
|
|
74
|
-
|
|
125
|
+
_Note: Never commit API keys or \`.env\` files to version control._
|
|
75
126
|
`;
|
|
76
127
|
}
|
|
77
128
|
|
package/src/utils/constants.js
CHANGED
|
@@ -121,12 +121,27 @@ export const PERFORMANCE_THRESHOLDS = {
|
|
|
121
121
|
};
|
|
122
122
|
|
|
123
123
|
export const AI_BOTS = [
|
|
124
|
-
'
|
|
125
|
-
'
|
|
126
|
-
'
|
|
127
|
-
'
|
|
128
|
-
'
|
|
129
|
-
'
|
|
130
|
-
'anthropic-ai',
|
|
131
|
-
'
|
|
124
|
+
{ userAgent: 'GPTBot', allow: '/' },
|
|
125
|
+
{ userAgent: 'OAI-SearchBot', allow: '/' },
|
|
126
|
+
{ userAgent: 'ChatGPT-User', allow: '/' },
|
|
127
|
+
{ userAgent: 'ClaudeBot', allow: '/' },
|
|
128
|
+
{ userAgent: 'Claude-User', allow: '/' },
|
|
129
|
+
{ userAgent: 'Claude-SearchBot', allow: '/' },
|
|
130
|
+
{ userAgent: 'anthropic-ai', allow: '/' },
|
|
131
|
+
{ userAgent: 'PerplexityBot', allow: '/' },
|
|
132
|
+
{ userAgent: 'Perplexity-User', allow: '/' },
|
|
133
|
+
{ userAgent: 'cohere-ai', allow: '/' },
|
|
134
|
+
{ userAgent: 'MistralAI-User', allow: '/' },
|
|
135
|
+
{ userAgent: 'Googlebot', allow: '/' },
|
|
136
|
+
{ userAgent: 'Google-Extended', allow: '/' },
|
|
137
|
+
{ userAgent: 'Bingbot', allow: '/' },
|
|
138
|
+
{ userAgent: 'Meta-ExternalAgent', allow: '/' },
|
|
139
|
+
{ userAgent: 'Meta-ExternalFetcher', allow: '/' },
|
|
140
|
+
{ userAgent: 'Applebot', allow: '/' },
|
|
141
|
+
{ userAgent: 'Applebot-Extended', allow: '/' },
|
|
142
|
+
{ userAgent: 'Bytespider', allow: '/' },
|
|
143
|
+
{ userAgent: 'Amazonbot', allow: '/' },
|
|
144
|
+
{ userAgent: 'CCBot', allow: '/' },
|
|
145
|
+
{ userAgent: 'diffbot', allow: '/' },
|
|
146
|
+
{ userAgent: 'webzio-extended', allow: '/' },
|
|
132
147
|
];
|