@ijonis/geo-lint 0.1.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +692 -0
- package/dist/cli.cjs +2716 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2689 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +2691 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +267 -0
- package/dist/index.d.ts +267 -0
- package/dist/index.js +2646 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
package/README.md
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
# @ijonis/geo-lint
|
|
2
|
+
|
|
3
|
+
**An agentic SEO and GEO linter. AI coding agents run it, read the violations, fix your content, and re-lint -- hands off.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@ijonis/geo-lint)
|
|
6
|
+
[](https://github.com/IJONIS/geo-lint/actions)
|
|
7
|
+
[](https://github.com/IJONIS/geo-lint/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why this exists
|
|
12
|
+
|
|
13
|
+
Traditional SEO tools give you a report. You read it, figure out what to change, edit the file, re-run, repeat. That loop is manual, slow, and breaks down at scale.
|
|
14
|
+
|
|
15
|
+
`@ijonis/geo-lint` is built for a different loop:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Agent runs geo-lint → reads JSON violations → fixes the content → re-runs geo-lint → done
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**You don't fix the violations. Your AI agent does.** The linter is the rule engine that tells the agent exactly what's wrong and how to fix it. Every rule ships with a machine-readable `fixStrategy` and a `suggestion` field that agents consume directly. The JSON output has zero ANSI formatting -- pure structured data.
|
|
22
|
+
|
|
23
|
+
This works today with Claude Code, Cursor, Windsurf, Copilot, or any agent that can run shell commands and edit files.
|
|
24
|
+
|
|
25
|
+
### What about GEO?
|
|
26
|
+
|
|
27
|
+
**GEO (Generative Engine Optimization)** is the practice of optimizing content so it gets cited by AI search engines -- ChatGPT, Perplexity, Google AI Overviews, Gemini. When someone asks an AI a question, the model pulls from web content to build its answer. GEO makes your content the source it pulls from.
|
|
28
|
+
|
|
29
|
+
Traditional SEO gets you into search result lists. GEO gets you **cited in AI-generated answers**. They're complementary, but GEO requires structural changes that no existing SEO tool checks for. `@ijonis/geo-lint` validates both -- 46 SEO rules and **7 dedicated GEO rules** that have zero open-source alternatives.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install -D @ijonis/geo-lint
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Create `geo-lint.config.ts`:
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import { defineConfig } from '@ijonis/geo-lint';
|
|
43
|
+
|
|
44
|
+
export default defineConfig({
|
|
45
|
+
siteUrl: 'https://your-site.com',
|
|
46
|
+
contentPaths: [{
|
|
47
|
+
dir: 'content/blog',
|
|
48
|
+
type: 'blog',
|
|
49
|
+
urlPrefix: '/blog/',
|
|
50
|
+
}],
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Run it manually:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx geo-lint
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Or let your agent handle it -- see [Agent Integration](#agent-integration) below.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## The 7 GEO Rules
|
|
65
|
+
|
|
66
|
+
No other open-source linter checks for these. Each rule targets a specific content pattern that AI search engines use when deciding what to cite. When your agent fixes a GEO violation, it's directly increasing the probability that the content gets pulled into AI-generated answers.
|
|
67
|
+
|
|
68
|
+
### 1. `geo-no-question-headings`
|
|
69
|
+
|
|
70
|
+
**At least 20% of H2/H3 headings should be phrased as questions.**
|
|
71
|
+
|
|
72
|
+
LLMs match user queries against headings to find relevant sections. Question-formatted headings create a direct mapping between what users ask and what your content answers.
|
|
73
|
+
|
|
74
|
+
**Before:**
|
|
75
|
+
```markdown
|
|
76
|
+
## Benefits of Remote Work
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**After:**
|
|
80
|
+
```markdown
|
|
81
|
+
## What are the benefits of remote work?
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 2. `geo-weak-lead-sentences`
|
|
85
|
+
|
|
86
|
+
**At least 50% of sections should start with a direct answer, not filler.**
|
|
87
|
+
|
|
88
|
+
AI systems use the first sentence after a heading as the citation snippet. Filler openings like "In this section, we will explore..." get skipped in favor of content that leads with the answer.
|
|
89
|
+
|
|
90
|
+
**Before:**
|
|
91
|
+
```markdown
|
|
92
|
+
## What is serverless computing?
|
|
93
|
+
|
|
94
|
+
In this section, we will take a closer look at serverless computing and
|
|
95
|
+
what it means for modern development teams.
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**After:**
|
|
99
|
+
```markdown
|
|
100
|
+
## What is serverless computing?
|
|
101
|
+
|
|
102
|
+
Serverless computing is a cloud execution model where the provider
|
|
103
|
+
dynamically allocates compute resources per request, eliminating the
|
|
104
|
+
need to provision or manage servers.
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 3. `geo-low-citation-density`
|
|
108
|
+
|
|
109
|
+
**Content needs at least 1 statistical data point per 500 words.**
|
|
110
|
+
|
|
111
|
+
AI answers prefer citable claims backed by numbers. A post that says "performance improved significantly" is less likely to be cited than one that says "performance improved by 47% in load testing."
|
|
112
|
+
|
|
113
|
+
**Before:**
|
|
114
|
+
```markdown
|
|
115
|
+
Adopting TypeScript significantly reduces bugs in large codebases.
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**After:**
|
|
119
|
+
```markdown
|
|
120
|
+
Adopting TypeScript reduces production bugs by 38% in codebases
|
|
121
|
+
exceeding 50,000 lines of code, according to a 2023 study by
|
|
122
|
+
Microsoft Research.
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 4. `geo-missing-faq-section`
|
|
126
|
+
|
|
127
|
+
**Long posts (800+ words) should include an FAQ section.**
|
|
128
|
+
|
|
129
|
+
FAQ sections are extracted verbatim by AI systems more than any other content structure. A well-written FAQ at the bottom of a post can generate more AI citations than the rest of the article combined.
|
|
130
|
+
|
|
131
|
+
**Before:**
|
|
132
|
+
```markdown
|
|
133
|
+
## Conclusion
|
|
134
|
+
|
|
135
|
+
TypeScript is a valuable tool for large teams.
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**After:**
|
|
139
|
+
```markdown
|
|
140
|
+
## FAQ
|
|
141
|
+
|
|
142
|
+
### Is TypeScript worth learning in 2026?
|
|
143
|
+
|
|
144
|
+
Yes. TypeScript is used by 78% of professional JavaScript developers
|
|
145
|
+
and is required in most enterprise job listings.
|
|
146
|
+
|
|
147
|
+
### Does TypeScript slow down development?
|
|
148
|
+
|
|
149
|
+
Initial setup adds overhead, but teams report 15-25% faster
|
|
150
|
+
iteration after the first month due to fewer runtime errors.
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### 5. `geo-missing-table`
|
|
154
|
+
|
|
155
|
+
**Long posts (1000+ words) should include at least one data table.**
|
|
156
|
+
|
|
157
|
+
Tables are highly structured and unambiguous, which makes them ideal for AI extraction. Research shows that content with comparison tables is cited 2.5x more frequently in AI-generated answers than equivalent content without tables.
|
|
158
|
+
|
|
159
|
+
**Before:**
|
|
160
|
+
```markdown
|
|
161
|
+
React is component-based and uses a virtual DOM. Vue is also
|
|
162
|
+
component-based but uses a reactivity system. Svelte compiles
|
|
163
|
+
components at build time.
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**After:**
|
|
167
|
+
```markdown
|
|
168
|
+
| Framework | Architecture | Bundle Size | Learning Curve |
|
|
169
|
+
|-----------|------------------|-------------|----------------|
|
|
170
|
+
| React | Virtual DOM | 42 KB | Moderate |
|
|
171
|
+
| Vue | Reactivity proxy | 33 KB | Low |
|
|
172
|
+
| Svelte | Compile-time | 1.6 KB | Low |
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### 6. `geo-short-citation-blocks`
|
|
176
|
+
|
|
177
|
+
**At least 50% of sections should start with a paragraph of 40+ words.**
|
|
178
|
+
|
|
179
|
+
The first paragraph after a heading is the "citation block" -- the unit of text that AI systems extract and present to users. If your opening paragraph is too short (a single sentence fragment), the AI may skip it or pull from a competitor's more complete answer.
|
|
180
|
+
|
|
181
|
+
**Before:**
|
|
182
|
+
```markdown
|
|
183
|
+
## How does DNS work?
|
|
184
|
+
|
|
185
|
+
It translates domain names.
|
|
186
|
+
|
|
187
|
+
DNS uses a hierarchical system of nameservers...
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**After:**
|
|
191
|
+
```markdown
|
|
192
|
+
## How does DNS work?
|
|
193
|
+
|
|
194
|
+
DNS (Domain Name System) translates human-readable domain names like
|
|
195
|
+
example.com into IP addresses that computers use to route traffic.
|
|
196
|
+
The resolution process queries a hierarchy of nameservers, starting
|
|
197
|
+
from root servers and drilling down through TLD and authoritative
|
|
198
|
+
nameservers to find the correct IP address.
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### 7. `geo-low-entity-density`
|
|
202
|
+
|
|
203
|
+
**Brand name and location should appear in the content body.**
|
|
204
|
+
|
|
205
|
+
AI systems build entity graphs that connect brands, locations, products, and topics. If your content never mentions your brand name or geographic context, the AI cannot associate the content with your entity -- even if the domain is correct.
|
|
206
|
+
|
|
207
|
+
This rule checks for the presence of the `brandName` and `brandCity` values from your config. When either value is empty, that check is skipped.
|
|
208
|
+
|
|
209
|
+
**Before:**
|
|
210
|
+
```markdown
|
|
211
|
+
Our team builds high-performance web applications using modern
|
|
212
|
+
frameworks and cloud infrastructure.
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**After:**
|
|
216
|
+
```markdown
|
|
217
|
+
ACME builds high-performance web applications from our Berlin
|
|
218
|
+
headquarters, using modern frameworks and cloud infrastructure.
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## All Rules
|
|
224
|
+
|
|
225
|
+
`@ijonis/geo-lint` ships with 53 rules across 5 categories. Here is a summary:
|
|
226
|
+
|
|
227
|
+
| Category | Rules | Severity Mix | Focus |
|
|
228
|
+
|----------|-------|-------------|-------|
|
|
229
|
+
| SEO | 27 | 6 errors, 21 warnings | Titles, descriptions, headings, slugs, OG images, canonical URLs, keywords, links, schema |
|
|
230
|
+
| Content | 7 | 2 errors, 5 warnings | Word count, readability, dates, categories |
|
|
231
|
+
| Technical | 9 | 3 errors, 6 warnings | Broken links, image files, trailing slashes, external URLs, performance |
|
|
232
|
+
| i18n | 2 | 0 errors, 2 warnings | Translation pairs, locale metadata |
|
|
233
|
+
| GEO | 7 | 0 errors, 7 warnings | AI citation readiness (see above) |
|
|
234
|
+
|
|
235
|
+
<details>
|
|
236
|
+
<summary>Full rule list</summary>
|
|
237
|
+
|
|
238
|
+
**Title (4 rules)**
|
|
239
|
+
|
|
240
|
+
| Rule | Severity | Description |
|
|
241
|
+
|------|----------|-------------|
|
|
242
|
+
| `title-missing` | error | Title must be present in frontmatter |
|
|
243
|
+
| `title-too-short` | warning | Title should meet minimum length (30 chars) |
|
|
244
|
+
| `title-too-long` | error | Title must not exceed maximum length (60 chars) |
|
|
245
|
+
| `title-approaching-limit` | warning | Title is close to the maximum length |
|
|
246
|
+
|
|
247
|
+
**Description (4 rules)**
|
|
248
|
+
|
|
249
|
+
| Rule | Severity | Description |
|
|
250
|
+
|------|----------|-------------|
|
|
251
|
+
| `description-missing` | error | Meta description must be present |
|
|
252
|
+
| `description-too-long` | error | Description must not exceed 160 characters |
|
|
253
|
+
| `description-approaching-limit` | warning | Description is close to the maximum length |
|
|
254
|
+
| `description-too-short` | warning | Description should meet minimum length (70 chars) |
|
|
255
|
+
|
|
256
|
+
**Heading (4 rules)**
|
|
257
|
+
|
|
258
|
+
| Rule | Severity | Description |
|
|
259
|
+
|------|----------|-------------|
|
|
260
|
+
| `missing-h1` | warning | Content should have an H1 heading |
|
|
261
|
+
| `multiple-h1` | error | Content must not have more than one H1 |
|
|
262
|
+
| `heading-hierarchy-skip` | warning | Heading levels should not skip (e.g., H2 to H4) |
|
|
263
|
+
| `duplicate-heading-text` | warning | Heading text should be unique within a page |
|
|
264
|
+
|
|
265
|
+
**Slug (2 rules)**
|
|
266
|
+
|
|
267
|
+
| Rule | Severity | Description |
|
|
268
|
+
|------|----------|-------------|
|
|
269
|
+
| `slug-invalid-characters` | error | Slugs must be lowercase alphanumeric with hyphens |
|
|
270
|
+
| `slug-too-long` | warning | Slugs should not exceed 75 characters |
|
|
271
|
+
|
|
272
|
+
**Open Graph (2 rules)**
|
|
273
|
+
|
|
274
|
+
| Rule | Severity | Description |
|
|
275
|
+
|------|----------|-------------|
|
|
276
|
+
| `blog-missing-og-image` | warning | Blog posts should have a featured image |
|
|
277
|
+
| `project-missing-og-image` | warning | Projects should have a featured image |
|
|
278
|
+
|
|
279
|
+
**Canonical (2 rules)**
|
|
280
|
+
|
|
281
|
+
| Rule | Severity | Description |
|
|
282
|
+
|------|----------|-------------|
|
|
283
|
+
| `canonical-missing` | warning | Indexed pages should have a canonical URL |
|
|
284
|
+
| `canonical-malformed` | warning | Canonical URL must be a valid path or site URL |
|
|
285
|
+
|
|
286
|
+
**Robots (1 rule)**
|
|
287
|
+
|
|
288
|
+
| Rule | Severity | Description |
|
|
289
|
+
|------|----------|-------------|
|
|
290
|
+
| `published-noindex` | warning | Published content with noindex may be unintentional |
|
|
291
|
+
|
|
292
|
+
**Schema (1 rule)**
|
|
293
|
+
|
|
294
|
+
| Rule | Severity | Description |
|
|
295
|
+
|------|----------|-------------|
|
|
296
|
+
| `blog-missing-schema-fields` | warning | Blog posts should have fields for BlogPosting schema |
|
|
297
|
+
|
|
298
|
+
**Keyword Coherence (3 rules)**
|
|
299
|
+
|
|
300
|
+
| Rule | Severity | Description |
|
|
301
|
+
|------|----------|-------------|
|
|
302
|
+
| `keyword-not-in-description` | warning | Title keywords should appear in the description |
|
|
303
|
+
| `keyword-not-in-headings` | warning | Title keywords should appear in subheadings |
|
|
304
|
+
| `title-description-no-overlap` | warning | Title and description should share keywords |
|
|
305
|
+
|
|
306
|
+
**Duplicate Detection (2 rules)**
|
|
307
|
+
|
|
308
|
+
| Rule | Severity | Description |
|
|
309
|
+
|------|----------|-------------|
|
|
310
|
+
| `duplicate-title` | error | Titles must be unique across all content |
|
|
311
|
+
| `duplicate-description` | error | Descriptions must be unique across all content |
|
|
312
|
+
|
|
313
|
+
**Link Validation (4 rules)**
|
|
314
|
+
|
|
315
|
+
| Rule | Severity | Description |
|
|
316
|
+
|------|----------|-------------|
|
|
317
|
+
| `broken-internal-link` | error | Internal links must resolve to existing pages |
|
|
318
|
+
| `absolute-internal-link` | warning | Internal links should use relative paths |
|
|
319
|
+
| `draft-link-leak` | error | Links must not point to draft or noindex pages |
|
|
320
|
+
| `trailing-slash-inconsistency` | warning | Internal links should not have trailing slashes |
|
|
321
|
+
|
|
322
|
+
**External Links (3 rules)**
|
|
323
|
+
|
|
324
|
+
| Rule | Severity | Description |
|
|
325
|
+
|------|----------|-------------|
|
|
326
|
+
| `external-link-malformed` | warning | External URLs must be well-formed |
|
|
327
|
+
| `external-link-http` | warning | External links should use HTTPS |
|
|
328
|
+
| `external-link-low-density` | warning | Blog posts should cite external sources |
|
|
329
|
+
|
|
330
|
+
**Image Validation (3 rules)**
|
|
331
|
+
|
|
332
|
+
| Rule | Severity | Description |
|
|
333
|
+
|------|----------|-------------|
|
|
334
|
+
| `inline-image-missing-alt` | error | Inline images must have alt text |
|
|
335
|
+
| `frontmatter-image-missing-alt` | warning | Featured images should have alt text |
|
|
336
|
+
| `image-not-found` | warning | Referenced images should exist on disk |
|
|
337
|
+
|
|
338
|
+
**Performance (1 rule)**
|
|
339
|
+
|
|
340
|
+
| Rule | Severity | Description |
|
|
341
|
+
|------|----------|-------------|
|
|
342
|
+
| `image-file-too-large` | warning | Image files should not exceed 500 KB |
|
|
343
|
+
|
|
344
|
+
**Orphan Detection (1 rule)**
|
|
345
|
+
|
|
346
|
+
| Rule | Severity | Description |
|
|
347
|
+
|------|----------|-------------|
|
|
348
|
+
| `orphan-content` | warning | Content should be linked from at least one other page |
|
|
349
|
+
|
|
350
|
+
**Content Quality (2 rules)**
|
|
351
|
+
|
|
352
|
+
| Rule | Severity | Description |
|
|
353
|
+
|------|----------|-------------|
|
|
354
|
+
| `content-too-short` | warning | Content should meet minimum word count (300) |
|
|
355
|
+
| `low-readability` | warning | Content should meet minimum readability score |
|
|
356
|
+
|
|
357
|
+
**Date Validation (3 rules)**
|
|
358
|
+
|
|
359
|
+
| Rule | Severity | Description |
|
|
360
|
+
|------|----------|-------------|
|
|
361
|
+
| `missing-date` | error | Blog and project content must have a date |
|
|
362
|
+
| `future-date` | warning | Date should not be in the future |
|
|
363
|
+
| `missing-updated-at` | warning | Content should have an updatedAt field |
|
|
364
|
+
|
|
365
|
+
**Category Validation (2 rules)**
|
|
366
|
+
|
|
367
|
+
| Rule | Severity | Description |
|
|
368
|
+
|------|----------|-------------|
|
|
369
|
+
| `category-invalid` | error | Categories must match the configured list |
|
|
370
|
+
| `missing-categories` | warning | Blog posts should have at least one category |
|
|
371
|
+
|
|
372
|
+
**i18n (2 rules)**
|
|
373
|
+
|
|
374
|
+
| Rule | Severity | Description |
|
|
375
|
+
|------|----------|-------------|
|
|
376
|
+
| `translation-pair-missing` | warning | Translated content should have both language versions |
|
|
377
|
+
| `missing-locale` | warning | Content should have a locale field |
|
|
378
|
+
|
|
379
|
+
**GEO (7 rules)**
|
|
380
|
+
|
|
381
|
+
| Rule | Severity | Description |
|
|
382
|
+
|------|----------|-------------|
|
|
383
|
+
| `geo-no-question-headings` | warning | At least 20% of headings should be questions |
|
|
384
|
+
| `geo-weak-lead-sentences` | warning | Sections should start with direct answers |
|
|
385
|
+
| `geo-low-citation-density` | warning | Content needs data points (1 per 500 words) |
|
|
386
|
+
| `geo-missing-faq-section` | warning | Long posts should include an FAQ section |
|
|
387
|
+
| `geo-missing-table` | warning | Long posts should include a data table |
|
|
388
|
+
| `geo-short-citation-blocks` | warning | Section lead paragraphs should be 40+ words |
|
|
389
|
+
| `geo-low-entity-density` | warning | Brand and location should appear in content |
|
|
390
|
+
|
|
391
|
+
</details>
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## Agent Integration
|
|
396
|
+
|
|
397
|
+
This is what `@ijonis/geo-lint` is built for. The linter isn't a reporting tool you read -- it's a **rule engine that governs your AI agent**. The agent runs the linter, reads the structured output, fixes every violation, and re-runs until the content is clean. You don't touch the content.
|
|
398
|
+
|
|
399
|
+
### How it works
|
|
400
|
+
|
|
401
|
+
```
|
|
402
|
+
┌─────────────────────────────────────────────────┐
|
|
403
|
+
│ You: "Optimize my blog posts for AI search" │
|
|
404
|
+
└──────────────────┬──────────────────────────────┘
|
|
405
|
+
▼
|
|
406
|
+
┌─────────────────────────────────────────────────┐
|
|
407
|
+
│ Agent runs: npx geo-lint --format=json │
|
|
408
|
+
│ ← Gets structured violations with fix guidance │
|
|
409
|
+
└──────────────────┬──────────────────────────────┘
|
|
410
|
+
▼
|
|
411
|
+
┌─────────────────────────────────────────────────┐
|
|
412
|
+
│ Agent reads each violation's `suggestion` │
|
|
413
|
+
│ Opens the file, applies the fix, saves it │
|
|
414
|
+
└──────────────────┬──────────────────────────────┘
|
|
415
|
+
▼
|
|
416
|
+
┌─────────────────────────────────────────────────┐
|
|
417
|
+
│ Agent re-runs: npx geo-lint --format=json │
|
|
418
|
+
│ Loops until violations = 0 │
|
|
419
|
+
└──────────────────┬──────────────────────────────┘
|
|
420
|
+
▼
|
|
421
|
+
┌─────────────────────────────────────────────────┐
|
|
422
|
+
│ Done. Content is GEO-optimized. │
|
|
423
|
+
└─────────────────────────────────────────────────┘
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
The entire loop is hands-off. Every rule includes:
|
|
427
|
+
- **`suggestion`** -- a plain-language instruction the agent follows to fix the violation
|
|
428
|
+
- **`fixStrategy`** -- a machine-readable fix description for the rule itself
|
|
429
|
+
- **`file`, `field`, `line`** -- exact location so the agent edits the right place
|
|
430
|
+
|
|
431
|
+
### JSON output (what the agent reads)
|
|
432
|
+
|
|
433
|
+
```bash
|
|
434
|
+
npx geo-lint --format=json
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
```json
|
|
438
|
+
[
|
|
439
|
+
{
|
|
440
|
+
"file": "blog/my-post",
|
|
441
|
+
"field": "body",
|
|
442
|
+
"rule": "geo-no-question-headings",
|
|
443
|
+
"severity": "warning",
|
|
444
|
+
"message": "Only 1/5 (20%) H2/H3 headings are question-formatted",
|
|
445
|
+
"suggestion": "Rephrase some headings as questions (e.g., 'How does X work?') to improve LLM snippet extraction."
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
"file": "blog/my-post",
|
|
449
|
+
"field": "body",
|
|
450
|
+
"rule": "geo-missing-table",
|
|
451
|
+
"severity": "warning",
|
|
452
|
+
"message": "No data table found in long-form content",
|
|
453
|
+
"suggestion": "Add a comparison table, feature matrix, or data summary table."
|
|
454
|
+
}
|
|
455
|
+
]
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
No ANSI colors. No human-friendly formatting. Pure structured data that any agent can parse and act on.
|
|
459
|
+
|
|
460
|
+
### Rule discovery (agent bootstrapping)
|
|
461
|
+
|
|
462
|
+
Before fixing anything, an agent can learn every rule and its fix strategy in one call:
|
|
463
|
+
|
|
464
|
+
```bash
|
|
465
|
+
npx geo-lint --rules
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
```json
|
|
469
|
+
[
|
|
470
|
+
{
|
|
471
|
+
"name": "geo-no-question-headings",
|
|
472
|
+
"severity": "warning",
|
|
473
|
+
"category": "geo",
|
|
474
|
+
"fixStrategy": "Rephrase some headings as questions (e.g., 'How does X work?')"
|
|
475
|
+
}
|
|
476
|
+
]
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### Drop-in Claude Code skill
|
|
480
|
+
|
|
481
|
+
Add this to your project's `.claude/skills/` and the agent will optimize your content autonomously:
|
|
482
|
+
|
|
483
|
+
```markdown
|
|
484
|
+
## GEO Lint & Fix
|
|
485
|
+
|
|
486
|
+
1. Run `npx geo-lint --format=json` and capture output
|
|
487
|
+
2. Parse the JSON array of violations
|
|
488
|
+
3. Group violations by file
|
|
489
|
+
4. For each file:
|
|
490
|
+
- Read the MDX file
|
|
491
|
+
- For each violation, apply the fix described in `suggestion`
|
|
492
|
+
- Preserve the author's voice -- don't rewrite, restructure
|
|
493
|
+
5. Re-run `npx geo-lint --format=json`
|
|
494
|
+
6. If violations remain, repeat from step 4 (max 3 passes)
|
|
495
|
+
7. Report: files changed, violations fixed, any remaining issues
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Works with **Claude Code**, **Cursor**, **Windsurf**, **Copilot**, or any agent that can run shell commands and edit files.
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
502
|
+
## Configuration Reference
|
|
503
|
+
|
|
504
|
+
Configuration is loaded from `geo-lint.config.ts` (also supports `.mjs` and `.js`), or from a `geoLint` key in `package.json`.
|
|
505
|
+
|
|
506
|
+
Use `defineConfig` for TypeScript autocomplete:
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
import { defineConfig } from '@ijonis/geo-lint';
|
|
510
|
+
|
|
511
|
+
export default defineConfig({
|
|
512
|
+
// Required: your canonical site URL
|
|
513
|
+
siteUrl: 'https://example.com',
|
|
514
|
+
|
|
515
|
+
// Content directories to scan (defaults shown)
|
|
516
|
+
contentPaths: [
|
|
517
|
+
{ dir: 'content/blog', type: 'blog', urlPrefix: '/blog/' },
|
|
518
|
+
{ dir: 'content/pages', type: 'page', urlPrefix: '/' },
|
|
519
|
+
{ dir: 'content/projects', type: 'project', urlPrefix: '/projects/' },
|
|
520
|
+
],
|
|
521
|
+
|
|
522
|
+
// Additional valid internal URLs for link validation
|
|
523
|
+
staticRoutes: ['/about', '/contact', '/pricing'],
|
|
524
|
+
|
|
525
|
+
// Directories to scan for image existence checks (default: ['public/images'])
|
|
526
|
+
imageDirectories: ['public/images'],
|
|
527
|
+
|
|
528
|
+
// Valid content categories (empty = skip category validation)
|
|
529
|
+
categories: ['engineering', 'design', 'business'],
|
|
530
|
+
|
|
531
|
+
// Slugs to exclude from linting
|
|
532
|
+
excludeSlugs: ['draft-post', 'test-page'],
|
|
533
|
+
|
|
534
|
+
// Content categories to exclude entirely (default: ['legal'])
|
|
535
|
+
excludeCategories: ['legal'],
|
|
536
|
+
|
|
537
|
+
// GEO-specific configuration
|
|
538
|
+
geo: {
|
|
539
|
+
brandName: 'ACME Corp', // Entity density check (empty = skip)
|
|
540
|
+
brandCity: 'Berlin', // Location entity check (empty = skip)
|
|
541
|
+
keywordsPath: '', // Reserved for future use
|
|
542
|
+
},
|
|
543
|
+
|
|
544
|
+
// Per-rule severity overrides ('error' | 'warning' | 'off')
|
|
545
|
+
rules: {
|
|
546
|
+
'geo-missing-table': 'off', // Disable a rule
|
|
547
|
+
'orphan-content': 'error', // Upgrade to error
|
|
548
|
+
'title-approaching-limit': 'off', // Disable a rule
|
|
549
|
+
},
|
|
550
|
+
|
|
551
|
+
// Threshold overrides
|
|
552
|
+
thresholds: {
|
|
553
|
+
title: { minLength: 30, maxLength: 60, warnLength: 55 },
|
|
554
|
+
description: { minLength: 70, maxLength: 160, warnLength: 150 },
|
|
555
|
+
slug: { maxLength: 75 },
|
|
556
|
+
content: { minWordCount: 300, minReadabilityScore: 30 },
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
### Configuration Options
|
|
562
|
+
|
|
563
|
+
| Option | Type | Required | Default | Description |
|
|
564
|
+
|--------|------|----------|---------|-------------|
|
|
565
|
+
| `siteUrl` | `string` | Yes | -- | Canonical site URL for link and canonical validation |
|
|
566
|
+
| `contentPaths` | `ContentPathConfig[]` | No | blog + pages + projects | Content directories to scan |
|
|
567
|
+
| `staticRoutes` | `string[]` | No | `[]` | Additional valid internal URLs |
|
|
568
|
+
| `imageDirectories` | `string[]` | No | `['public/images']` | Directories to scan for images |
|
|
569
|
+
| `categories` | `string[]` | No | `[]` | Valid content categories |
|
|
570
|
+
| `excludeSlugs` | `string[]` | No | `[]` | Slugs to skip during linting |
|
|
571
|
+
| `excludeCategories` | `string[]` | No | `['legal']` | Categories to skip entirely |
|
|
572
|
+
| `geo` | `GeoConfig` | No | `{}` | GEO entity density configuration |
|
|
573
|
+
| `rules` | `Record<string, Severity>` | No | `{}` | Per-rule severity overrides |
|
|
574
|
+
| `thresholds` | `ThresholdConfig` | No | See above | Length and quality thresholds |
|
|
575
|
+
|
|
576
|
+
### ContentPathConfig
|
|
577
|
+
|
|
578
|
+
```typescript
|
|
579
|
+
interface ContentPathConfig {
|
|
580
|
+
dir: string; // Relative path from project root
|
|
581
|
+
type: 'blog' | 'page' | 'project';
|
|
582
|
+
urlPrefix?: string; // URL prefix for permalink derivation
|
|
583
|
+
defaultLocale?: string; // Default locale when frontmatter has none
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
---
|
|
588
|
+
|
|
589
|
+
## Custom Adapters
|
|
590
|
+
|
|
591
|
+
By default, `@ijonis/geo-lint` scans Markdown and MDX files using `gray-matter` for frontmatter parsing. If your content lives in a CMS, database, or custom format, you can provide a custom adapter:
|
|
592
|
+
|
|
593
|
+
```typescript
|
|
594
|
+
import { lint, createAdapter } from '@ijonis/geo-lint';
|
|
595
|
+
|
|
596
|
+
const adapter = createAdapter(async (projectRoot) => {
|
|
597
|
+
// Fetch from your CMS, database, or API
|
|
598
|
+
const posts = await fetchFromCMS();
|
|
599
|
+
|
|
600
|
+
return posts.map(post => ({
|
|
601
|
+
title: post.title,
|
|
602
|
+
slug: post.slug,
|
|
603
|
+
description: post.metaDescription,
|
|
604
|
+
permalink: `/blog/${post.slug}`,
|
|
605
|
+
body: post.markdownContent,
|
|
606
|
+
contentType: 'blog' as const,
|
|
607
|
+
filePath: `virtual/${post.slug}.mdx`,
|
|
608
|
+
rawContent: post.markdownContent,
|
|
609
|
+
// Optional fields
|
|
610
|
+
image: post.featuredImage,
|
|
611
|
+
imageAlt: post.featuredImageAlt,
|
|
612
|
+
date: post.publishedAt,
|
|
613
|
+
locale: post.language,
|
|
614
|
+
categories: post.tags,
|
|
615
|
+
}));
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const exitCode = await lint({ adapter });
|
|
619
|
+
process.exit(exitCode);
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
The adapter receives the project root path and must return an array of `ContentItem` objects. All standard rules run against the returned items.
|
|
623
|
+
|
|
624
|
+
---
|
|
625
|
+
|
|
626
|
+
## Programmatic API
|
|
627
|
+
|
|
628
|
+
Use `lint()` for full output or `lintQuiet()` for raw results without console output:
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
import { lint, lintQuiet } from '@ijonis/geo-lint';
|
|
632
|
+
|
|
633
|
+
// Full lint with formatted console output
|
|
634
|
+
const exitCode = await lint({
|
|
635
|
+
projectRoot: './my-project',
|
|
636
|
+
format: 'json',
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// Quiet mode: returns raw LintResult[] with no console output
|
|
640
|
+
const results = await lintQuiet({
|
|
641
|
+
projectRoot: './my-project',
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Filter and process results programmatically
|
|
645
|
+
const geoViolations = results.filter(r => r.rule.startsWith('geo-'));
|
|
646
|
+
const errors = results.filter(r => r.severity === 'error');
|
|
647
|
+
|
|
648
|
+
console.log(`${geoViolations.length} GEO issues found`);
|
|
649
|
+
console.log(`${errors.length} errors (will block build)`);
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
### LintResult Type
|
|
653
|
+
|
|
654
|
+
```typescript
|
|
655
|
+
interface LintResult {
|
|
656
|
+
file: string; // Relative path (e.g., "blog/my-post")
|
|
657
|
+
field: string; // Field checked (e.g., "title", "body", "image")
|
|
658
|
+
rule: string; // Rule identifier (e.g., "geo-no-question-headings")
|
|
659
|
+
severity: 'error' | 'warning';
|
|
660
|
+
message: string; // Human-readable violation description
|
|
661
|
+
suggestion?: string; // Actionable fix suggestion
|
|
662
|
+
line?: number; // Line number in source file (when applicable)
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
---
|
|
667
|
+
|
|
668
|
+
## CLI Reference
|
|
669
|
+
|
|
670
|
+
```
|
|
671
|
+
Usage:
|
|
672
|
+
geo-lint [options]
|
|
673
|
+
|
|
674
|
+
Options:
|
|
675
|
+
--root=<path> Project root directory (default: cwd)
|
|
676
|
+
--config=<path> Explicit config file path
|
|
677
|
+
--format=pretty Human-readable colored output (default)
|
|
678
|
+
--format=json Machine-readable JSON output (for AI agents)
|
|
679
|
+
--rules List all registered rules with fix strategies
|
|
680
|
+
-h, --help Show this help message
|
|
681
|
+
-v, --version Show version
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
---
|
|
685
|
+
|
|
686
|
+
## License
|
|
687
|
+
|
|
688
|
+
[MIT](LICENSE)
|
|
689
|
+
|
|
690
|
+
---
|
|
691
|
+
|
|
692
|
+
Built by [IJONIS](https://ijonis.com) -- we help companies become visible to AI search engines. This linter is extracted from the same toolchain we use on production client content.
|