@ijonis/geo-lint 0.1.1 → 0.1.3
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 +19 -4
- package/README.md +66 -800
- package/dist/cli.cjs +18 -13
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +18 -13
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,32 +1,24 @@
|
|
|
1
1
|
# @ijonis/geo-lint
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**The first open-source linter for GEO (Generative Engine Optimization). Validates your content for AI search visibility -- then lets your AI agent fix it automatically.**
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@ijonis/geo-lint)
|
|
6
6
|
[](https://github.com/IJONIS/geo-lint/actions)
|
|
7
7
|
[](https://github.com/IJONIS/geo-lint/blob/main/LICENSE)
|
|
8
8
|
|
|
9
|
+

|
|
10
|
+
|
|
9
11
|
---
|
|
10
12
|
|
|
11
13
|
## Why this exists
|
|
12
14
|
|
|
13
|
-
|
|
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.
|
|
15
|
+
I run multiple content-heavy sites and there was no deterministic way to validate whether my content was actually optimized -- not "probably fine," but actually checked against concrete rules. SEO linters exist, but they're either paid SaaS, not automatable, or completely ignore the structural patterns that AI search engines use when deciding what to cite.
|
|
22
16
|
|
|
23
|
-
|
|
17
|
+
So I built one. **GEO (Generative Engine Optimization)** is the practice of structuring content so it gets cited by ChatGPT, Perplexity, Google AI Overviews, and Gemini. Traditional SEO gets you into search result lists. GEO gets you **cited in AI-generated answers**. Both matter -- and no existing open-source tool checks for GEO.
|
|
24
18
|
|
|
25
|
-
|
|
19
|
+
The goal was simple: install one tool, point your AI agent at it, and walk away. The agent runs the linter, reads the JSON violations, fixes the content, re-lints until clean -- across an entire site, no manual input. One command, both SEO and GEO validated.
|
|
26
20
|
|
|
27
|
-
**
|
|
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 -- 32 SEO rules, **35 dedicated GEO rules**, and **14 content quality rules** including readability analysis inspired by Yoast SEO -- with zero open-source alternatives for the GEO checks.
|
|
21
|
+
**92 rules: 35 GEO, 32 SEO, 14 content quality, 8 technical, 3 i18n.** Readability analysis inspired by Yoast SEO. We researched the current state of GEO and AEO to make sure the rules reflect what actually gets content cited -- not outdated advice.
|
|
30
22
|
|
|
31
23
|
---
|
|
32
24
|
|
|
@@ -51,45 +43,24 @@ export default defineConfig({
|
|
|
51
43
|
});
|
|
52
44
|
```
|
|
53
45
|
|
|
54
|
-
Run it
|
|
46
|
+
Run it:
|
|
55
47
|
|
|
56
48
|
```bash
|
|
57
|
-
npx geo-lint
|
|
49
|
+
npx geo-lint # Human-readable output
|
|
50
|
+
npx geo-lint --format=json # Machine-readable for AI agents
|
|
58
51
|
```
|
|
59
52
|
|
|
60
|
-
|
|
53
|
+
Works out of the box with `.md`/`.mdx` files. For Astro, HTML, or other formats, see [Custom Adapters](docs/custom-adapters.md).
|
|
61
54
|
|
|
62
55
|
---
|
|
63
56
|
|
|
64
|
-
## GEO
|
|
65
|
-
|
|
66
|
-
No other open-source linter checks for these. 35 rules across E-E-A-T signals, content structure, freshness, and RAG optimization -- each targeting 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
|
-
> **New in 0.1.1:** 14 content quality rules now include transition word analysis, consecutive sentence start detection, and sentence length variety scoring -- readability checks inspired by Yoast SEO, built for the agentic lint-fix loop.
|
|
57
|
+
## GEO in Action
|
|
69
58
|
|
|
70
|
-
|
|
59
|
+
Three examples of what GEO rules catch and how to fix them. See [all 7 core GEO rules with examples](docs/geo-rules.md).
|
|
71
60
|
|
|
72
|
-
###
|
|
61
|
+
### `geo-weak-lead-sentences`
|
|
73
62
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
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.
|
|
77
|
-
|
|
78
|
-
**Before:**
|
|
79
|
-
```markdown
|
|
80
|
-
## Benefits of Remote Work
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
**After:**
|
|
84
|
-
```markdown
|
|
85
|
-
## What are the benefits of remote work?
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
### 2. `geo-weak-lead-sentences`
|
|
89
|
-
|
|
90
|
-
**At least 50% of sections should start with a direct answer, not filler.**
|
|
91
|
-
|
|
92
|
-
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.
|
|
63
|
+
AI systems use the first sentence after a heading as the citation snippet. Filler openings get skipped.
|
|
93
64
|
|
|
94
65
|
**Before:**
|
|
95
66
|
```markdown
|
|
@@ -108,11 +79,9 @@ dynamically allocates compute resources per request, eliminating the
|
|
|
108
79
|
need to provision or manage servers.
|
|
109
80
|
```
|
|
110
81
|
|
|
111
|
-
###
|
|
82
|
+
### `geo-low-citation-density`
|
|
112
83
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
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."
|
|
84
|
+
AI answers prefer citable claims backed by numbers. Vague statements get passed over.
|
|
116
85
|
|
|
117
86
|
**Before:**
|
|
118
87
|
```markdown
|
|
@@ -126,39 +95,9 @@ exceeding 50,000 lines of code, according to a 2023 study by
|
|
|
126
95
|
Microsoft Research.
|
|
127
96
|
```
|
|
128
97
|
|
|
129
|
-
###
|
|
130
|
-
|
|
131
|
-
**Long posts (800+ words) should include an FAQ section.**
|
|
132
|
-
|
|
133
|
-
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.
|
|
134
|
-
|
|
135
|
-
**Before:**
|
|
136
|
-
```markdown
|
|
137
|
-
## Conclusion
|
|
138
|
-
|
|
139
|
-
TypeScript is a valuable tool for large teams.
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
**After:**
|
|
143
|
-
```markdown
|
|
144
|
-
## FAQ
|
|
145
|
-
|
|
146
|
-
### Is TypeScript worth learning in 2026?
|
|
147
|
-
|
|
148
|
-
Yes. TypeScript is used by 78% of professional JavaScript developers
|
|
149
|
-
and is required in most enterprise job listings.
|
|
98
|
+
### `geo-missing-table`
|
|
150
99
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
Initial setup adds overhead, but teams report 15-25% faster
|
|
154
|
-
iteration after the first month due to fewer runtime errors.
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
### 5. `geo-missing-table`
|
|
158
|
-
|
|
159
|
-
**Long posts (1000+ words) should include at least one data table.**
|
|
160
|
-
|
|
161
|
-
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.
|
|
100
|
+
Tables are highly structured and unambiguous -- ideal for AI extraction. Content with comparison tables is cited significantly more often than equivalent prose.
|
|
162
101
|
|
|
163
102
|
**Before:**
|
|
164
103
|
```markdown
|
|
@@ -176,57 +115,9 @@ components at build time.
|
|
|
176
115
|
| Svelte | Compile-time | 1.6 KB | Low |
|
|
177
116
|
```
|
|
178
117
|
|
|
179
|
-
### 6. `geo-short-citation-blocks`
|
|
180
|
-
|
|
181
|
-
**At least 50% of sections should start with a paragraph of 40+ words.**
|
|
182
|
-
|
|
183
|
-
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.
|
|
184
|
-
|
|
185
|
-
**Before:**
|
|
186
|
-
```markdown
|
|
187
|
-
## How does DNS work?
|
|
188
|
-
|
|
189
|
-
It translates domain names.
|
|
190
|
-
|
|
191
|
-
DNS uses a hierarchical system of nameservers...
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
**After:**
|
|
195
|
-
```markdown
|
|
196
|
-
## How does DNS work?
|
|
197
|
-
|
|
198
|
-
DNS (Domain Name System) translates human-readable domain names like
|
|
199
|
-
example.com into IP addresses that computers use to route traffic.
|
|
200
|
-
The resolution process queries a hierarchy of nameservers, starting
|
|
201
|
-
from root servers and drilling down through TLD and authoritative
|
|
202
|
-
nameservers to find the correct IP address.
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
### 7. `geo-low-entity-density`
|
|
206
|
-
|
|
207
|
-
**Brand name and location should appear in the content body.**
|
|
208
|
-
|
|
209
|
-
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.
|
|
210
|
-
|
|
211
|
-
This rule checks for the presence of the `brandName` and `brandCity` values from your config. When either value is empty, that check is skipped.
|
|
212
|
-
|
|
213
|
-
**Before:**
|
|
214
|
-
```markdown
|
|
215
|
-
Our team builds high-performance web applications using modern
|
|
216
|
-
frameworks and cloud infrastructure.
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
**After:**
|
|
220
|
-
```markdown
|
|
221
|
-
ACME builds high-performance web applications from our Berlin
|
|
222
|
-
headquarters, using modern frameworks and cloud infrastructure.
|
|
223
|
-
```
|
|
224
|
-
|
|
225
118
|
---
|
|
226
119
|
|
|
227
|
-
## All Rules
|
|
228
|
-
|
|
229
|
-
`@ijonis/geo-lint` ships with 92 rules across 5 categories. Here is a summary:
|
|
120
|
+
## All 92 Rules
|
|
230
121
|
|
|
231
122
|
| Category | Rules | Severity Mix | Focus |
|
|
232
123
|
|----------|-------|-------------|-------|
|
|
@@ -236,258 +127,40 @@ headquarters, using modern frameworks and cloud infrastructure.
|
|
|
236
127
|
| i18n | 3 | 0 errors, 3 warnings | Translation pairs, locale metadata |
|
|
237
128
|
| GEO | 35 | 0 errors, 35 warnings | AI citation readiness: E-E-A-T signals, content structure, freshness, RAG optimization |
|
|
238
129
|
|
|
239
|
-
|
|
240
|
-
<summary>Full rule list</summary>
|
|
241
|
-
|
|
242
|
-
**Title (4 rules)**
|
|
243
|
-
|
|
244
|
-
| Rule | Severity | Description |
|
|
245
|
-
|------|----------|-------------|
|
|
246
|
-
| `title-missing` | error | Title must be present in frontmatter |
|
|
247
|
-
| `title-too-short` | warning | Title should meet minimum length (30 chars) |
|
|
248
|
-
| `title-too-long` | error | Title must not exceed maximum length (60 chars) |
|
|
249
|
-
| `title-approaching-limit` | warning | Title is close to the maximum length |
|
|
250
|
-
|
|
251
|
-
**Description (4 rules)**
|
|
130
|
+
See the [complete rule reference](docs/rules.md) with descriptions and severity for every rule.
|
|
252
131
|
|
|
253
|
-
|
|
254
|
-
|------|----------|-------------|
|
|
255
|
-
| `description-missing` | error | Meta description must be present |
|
|
256
|
-
| `description-too-long` | error | Description must not exceed 160 characters |
|
|
257
|
-
| `description-approaching-limit` | warning | Description is close to the maximum length |
|
|
258
|
-
| `description-too-short` | warning | Description should meet minimum length (70 chars) |
|
|
259
|
-
|
|
260
|
-
**Heading (4 rules)**
|
|
261
|
-
|
|
262
|
-
| Rule | Severity | Description |
|
|
263
|
-
|------|----------|-------------|
|
|
264
|
-
| `missing-h1` | warning | Content should have an H1 heading |
|
|
265
|
-
| `multiple-h1` | error | Content must not have more than one H1 |
|
|
266
|
-
| `heading-hierarchy-skip` | warning | Heading levels should not skip (e.g., H2 to H4) |
|
|
267
|
-
| `duplicate-heading-text` | warning | Heading text should be unique within a page |
|
|
268
|
-
|
|
269
|
-
**Slug (2 rules)**
|
|
270
|
-
|
|
271
|
-
| Rule | Severity | Description |
|
|
272
|
-
|------|----------|-------------|
|
|
273
|
-
| `slug-invalid-characters` | error | Slugs must be lowercase alphanumeric with hyphens |
|
|
274
|
-
| `slug-too-long` | warning | Slugs should not exceed 75 characters |
|
|
275
|
-
|
|
276
|
-
**Open Graph (2 rules)**
|
|
277
|
-
|
|
278
|
-
| Rule | Severity | Description |
|
|
279
|
-
|------|----------|-------------|
|
|
280
|
-
| `blog-missing-og-image` | warning | Blog posts should have a featured image |
|
|
281
|
-
| `project-missing-og-image` | warning | Projects should have a featured image |
|
|
282
|
-
|
|
283
|
-
**Canonical (2 rules)**
|
|
284
|
-
|
|
285
|
-
| Rule | Severity | Description |
|
|
286
|
-
|------|----------|-------------|
|
|
287
|
-
| `canonical-missing` | warning | Indexed pages should have a canonical URL |
|
|
288
|
-
| `canonical-malformed` | warning | Canonical URL must be a valid path or site URL |
|
|
289
|
-
|
|
290
|
-
**Robots (1 rule)**
|
|
291
|
-
|
|
292
|
-
| Rule | Severity | Description |
|
|
293
|
-
|------|----------|-------------|
|
|
294
|
-
| `published-noindex` | warning | Published content with noindex may be unintentional |
|
|
295
|
-
|
|
296
|
-
**Schema (1 rule)**
|
|
297
|
-
|
|
298
|
-
| Rule | Severity | Description |
|
|
299
|
-
|------|----------|-------------|
|
|
300
|
-
| `blog-missing-schema-fields` | warning | Blog posts should have fields for BlogPosting schema |
|
|
301
|
-
|
|
302
|
-
**Keyword Coherence (3 rules)**
|
|
132
|
+
---
|
|
303
133
|
|
|
304
|
-
|
|
305
|
-
|------|----------|-------------|
|
|
306
|
-
| `keyword-not-in-description` | warning | Title keywords should appear in the description |
|
|
307
|
-
| `keyword-not-in-headings` | warning | Title keywords should appear in subheadings |
|
|
308
|
-
| `title-description-no-overlap` | warning | Title and description should share keywords |
|
|
134
|
+
## Works With
|
|
309
135
|
|
|
310
|
-
**
|
|
136
|
+
**AI agents**: Claude Code, Cursor, Windsurf, GitHub Copilot -- any agent that can run shell commands and edit files
|
|
311
137
|
|
|
312
|
-
|
|
313
|
-
|------|----------|-------------|
|
|
314
|
-
| `duplicate-title` | error | Titles must be unique across all content |
|
|
315
|
-
| `duplicate-description` | error | Descriptions must be unique across all content |
|
|
138
|
+
**Content formats**: Markdown and MDX out of the box. Astro, HTML, Nuxt, any CMS via [custom adapters](docs/custom-adapters.md)
|
|
316
139
|
|
|
317
|
-
**
|
|
140
|
+
**Build tools**: Runs in any CI pipeline. JSON output for programmatic consumption
|
|
318
141
|
|
|
319
|
-
|
|
320
|
-
|------|----------|-------------|
|
|
321
|
-
| `broken-internal-link` | error | Internal links must resolve to existing pages |
|
|
322
|
-
| `absolute-internal-link` | warning | Internal links should use relative paths |
|
|
323
|
-
| `draft-link-leak` | error | Links must not point to draft or noindex pages |
|
|
324
|
-
| `trailing-slash-inconsistency` | warning | Internal links should not have trailing slashes |
|
|
325
|
-
|
|
326
|
-
**External Links (3 rules)**
|
|
327
|
-
|
|
328
|
-
| Rule | Severity | Description |
|
|
329
|
-
|------|----------|-------------|
|
|
330
|
-
| `external-link-malformed` | warning | External URLs must be well-formed |
|
|
331
|
-
| `external-link-http` | warning | External links should use HTTPS |
|
|
332
|
-
| `external-link-low-density` | warning | Blog posts should cite external sources |
|
|
333
|
-
|
|
334
|
-
**Image Validation (3 rules)**
|
|
335
|
-
|
|
336
|
-
| Rule | Severity | Description |
|
|
337
|
-
|------|----------|-------------|
|
|
338
|
-
| `inline-image-missing-alt` | error | Inline images must have alt text |
|
|
339
|
-
| `frontmatter-image-missing-alt` | warning | Featured images should have alt text |
|
|
340
|
-
| `image-not-found` | warning | Referenced images should exist on disk |
|
|
341
|
-
|
|
342
|
-
**Performance (1 rule)**
|
|
343
|
-
|
|
344
|
-
| Rule | Severity | Description |
|
|
345
|
-
|------|----------|-------------|
|
|
346
|
-
| `image-file-too-large` | warning | Image files should not exceed 500 KB |
|
|
347
|
-
|
|
348
|
-
**Orphan Detection (1 rule)**
|
|
349
|
-
|
|
350
|
-
| Rule | Severity | Description |
|
|
351
|
-
|------|----------|-------------|
|
|
352
|
-
| `orphan-content` | warning | Content should be linked from at least one other page |
|
|
353
|
-
|
|
354
|
-
**Content Quality (14 rules)**
|
|
355
|
-
|
|
356
|
-
| Rule | Severity | Description |
|
|
357
|
-
|------|----------|-------------|
|
|
358
|
-
| `content-too-short` | warning | Content should meet minimum word count (300) |
|
|
359
|
-
| `low-readability` | warning | Content should meet minimum readability score |
|
|
360
|
-
| `content-jargon-density` | warning | Complex/uncommon word density exceeds 8% (error at 15%) |
|
|
361
|
-
| `content-repetition` | warning | High paragraph similarity or repeated phrases |
|
|
362
|
-
| `content-sentence-length-extreme` | warning | Average sentence length exceeds 35 words (error at 50) |
|
|
363
|
-
| `content-substance-ratio` | warning | Low vocabulary diversity (type-token ratio below 25%) |
|
|
364
|
-
| `content-low-transition-words` | warning | Fewer than 20% of sentences contain transition words (error at 10%) |
|
|
365
|
-
| `content-consecutive-starts` | warning | 3+ consecutive sentences start with the same word (error at 5+) |
|
|
366
|
-
| `content-sentence-variety` | warning | Monotonous sentence lengths (coefficient of variation below 0.30) |
|
|
367
|
-
|
|
368
|
-
**Date Validation (3 rules)**
|
|
369
|
-
|
|
370
|
-
| Rule | Severity | Description |
|
|
371
|
-
|------|----------|-------------|
|
|
372
|
-
| `missing-date` | error | Blog and project content must have a date |
|
|
373
|
-
| `future-date` | warning | Date should not be in the future |
|
|
374
|
-
| `missing-updated-at` | warning | Content should have an updatedAt field |
|
|
375
|
-
|
|
376
|
-
**Category Validation (2 rules)**
|
|
377
|
-
|
|
378
|
-
| Rule | Severity | Description |
|
|
379
|
-
|------|----------|-------------|
|
|
380
|
-
| `category-invalid` | error | Categories must match the configured list |
|
|
381
|
-
| `missing-categories` | warning | Blog posts should have at least one category |
|
|
382
|
-
|
|
383
|
-
**i18n (2 rules)**
|
|
384
|
-
|
|
385
|
-
| Rule | Severity | Description |
|
|
386
|
-
|------|----------|-------------|
|
|
387
|
-
| `translation-pair-missing` | warning | Translated content should have both language versions |
|
|
388
|
-
| `missing-locale` | warning | Content should have a locale field |
|
|
389
|
-
|
|
390
|
-
**GEO — Core (7 rules)**
|
|
391
|
-
|
|
392
|
-
| Rule | Severity | Description |
|
|
393
|
-
|------|----------|-------------|
|
|
394
|
-
| `geo-no-question-headings` | warning | At least 20% of headings should be questions |
|
|
395
|
-
| `geo-weak-lead-sentences` | warning | Sections should start with direct answers |
|
|
396
|
-
| `geo-low-citation-density` | warning | Content needs data points (1 per 500 words) |
|
|
397
|
-
| `geo-missing-faq-section` | warning | Long posts should include an FAQ section |
|
|
398
|
-
| `geo-missing-table` | warning | Long posts should include a data table |
|
|
399
|
-
| `geo-short-citation-blocks` | warning | Section lead paragraphs should be 40+ words |
|
|
400
|
-
| `geo-low-entity-density` | warning | Brand and location should appear in content |
|
|
401
|
-
|
|
402
|
-
**GEO — E-E-A-T (8 rules)**
|
|
403
|
-
|
|
404
|
-
| Rule | Severity | Description |
|
|
405
|
-
|------|----------|-------------|
|
|
406
|
-
| `geo-missing-source-citations` | warning | Min 1 source citation per 500 words |
|
|
407
|
-
| `geo-missing-expert-quotes` | warning | Long posts need at least 1 attributed blockquote |
|
|
408
|
-
| `geo-missing-author` | warning | Blog posts need a non-generic author name |
|
|
409
|
-
| `geo-heading-too-vague` | warning | Headings must be 3+ words and not generic |
|
|
410
|
-
| `geo-faq-quality` | warning | FAQ sections need 3+ Q&A pairs with proper formatting |
|
|
411
|
-
| `geo-definition-pattern` | warning | "What is X?" headings should start with "X is..." |
|
|
412
|
-
| `geo-howto-steps` | warning | "How to" headings need 3+ numbered steps |
|
|
413
|
-
| `geo-missing-tldr` | warning | Long posts need a TL;DR or key takeaway near the top |
|
|
414
|
-
|
|
415
|
-
**GEO — Structure (7 rules)**
|
|
416
|
-
|
|
417
|
-
| Rule | Severity | Description |
|
|
418
|
-
|------|----------|-------------|
|
|
419
|
-
| `geo-section-too-long` | warning | H2 sections over 300 words need H3 sub-headings |
|
|
420
|
-
| `geo-paragraph-too-long` | warning | Paragraphs should not exceed 100 words |
|
|
421
|
-
| `geo-missing-lists` | warning | Content should include at least one list |
|
|
422
|
-
| `geo-citation-block-upper-bound` | warning | First paragraph after H2 should be under 80 words |
|
|
423
|
-
| `geo-orphaned-intro` | warning | Introduction before first H2 should be under 150 words |
|
|
424
|
-
| `geo-heading-density` | warning | No text gap should exceed 300 words without a heading |
|
|
425
|
-
| `geo-structural-element-ratio` | warning | At least 1 structural element per 500 words |
|
|
426
|
-
|
|
427
|
-
**GEO — Freshness & Quality (7 rules)**
|
|
428
|
-
|
|
429
|
-
| Rule | Severity | Description |
|
|
430
|
-
|------|----------|-------------|
|
|
431
|
-
| `geo-stale-date-references` | warning | Year references older than 18 months |
|
|
432
|
-
| `geo-outdated-content` | warning | Content not updated in over 6 months |
|
|
433
|
-
| `geo-passive-voice-excess` | warning | Over 15% passive voice sentences |
|
|
434
|
-
| `geo-sentence-too-long` | warning | Sentences exceeding 40 words |
|
|
435
|
-
| `geo-low-internal-links` | warning | Fewer than 2 internal links |
|
|
436
|
-
| `geo-comparison-table` | warning | Comparison headings without a data table |
|
|
437
|
-
| `geo-inline-html` | warning | Raw HTML tags in markdown content |
|
|
438
|
-
|
|
439
|
-
**GEO — RAG Optimization (6 rules)**
|
|
440
|
-
|
|
441
|
-
| Rule | Severity | Description |
|
|
442
|
-
|------|----------|-------------|
|
|
443
|
-
| `geo-extraction-triggers` | warning | Long posts need summary/takeaway phrases |
|
|
444
|
-
| `geo-section-self-containment` | warning | Sections should not open with unresolved pronouns |
|
|
445
|
-
| `geo-vague-opening` | warning | Articles should not start with filler phrases |
|
|
446
|
-
| `geo-acronym-expansion` | warning | Acronyms must be expanded on first use |
|
|
447
|
-
| `geo-statistic-without-context` | warning | Statistics need source attribution or timeframe |
|
|
448
|
-
| `geo-missing-summary-section` | warning | Long posts (2000+ words) need a summary section |
|
|
449
|
-
|
|
450
|
-
</details>
|
|
142
|
+
**Runtime**: Node.js >= 18. Zero peer dependencies
|
|
451
143
|
|
|
452
144
|
---
|
|
453
145
|
|
|
454
|
-
## Agent
|
|
146
|
+
## Agent-First Design
|
|
147
|
+
|
|
148
|
+
This linter is **deterministic** -- same content in, same violations out, every time. Your AI agent provides the creativity to fix the content; geo-lint provides the guardrails to verify it's correct. The loop runs until violations hit zero.
|
|
455
149
|
|
|
456
|
-
|
|
150
|
+
### Try it now
|
|
457
151
|
|
|
458
|
-
|
|
152
|
+
Paste this into **Claude Code**, **Cursor**, or any AI coding agent:
|
|
459
153
|
|
|
460
154
|
```
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
┌─────────────────────────────────────────────────┐
|
|
466
|
-
│ Agent runs: npx geo-lint --format=json │
|
|
467
|
-
│ ← Gets structured violations with fix guidance │
|
|
468
|
-
└──────────────────┬──────────────────────────────┘
|
|
469
|
-
▼
|
|
470
|
-
┌─────────────────────────────────────────────────┐
|
|
471
|
-
│ Agent reads each violation's `suggestion` │
|
|
472
|
-
│ Opens the file, applies the fix, saves it │
|
|
473
|
-
└──────────────────┬──────────────────────────────┘
|
|
474
|
-
▼
|
|
475
|
-
┌─────────────────────────────────────────────────┐
|
|
476
|
-
│ Agent re-runs: npx geo-lint --format=json │
|
|
477
|
-
│ Loops until violations = 0 │
|
|
478
|
-
└──────────────────┬──────────────────────────────┘
|
|
479
|
-
▼
|
|
480
|
-
┌─────────────────────────────────────────────────┐
|
|
481
|
-
│ Done. Content is GEO-optimized. │
|
|
482
|
-
└─────────────────────────────────────────────────┘
|
|
155
|
+
Run npx geo-lint --format=json, then fix every violation in the reported
|
|
156
|
+
files using each violation's suggestion field. After fixing, re-run the
|
|
157
|
+
linter and repeat until the output is an empty array []. Preserve the
|
|
158
|
+
author's voice -- restructure, don't rewrite.
|
|
483
159
|
```
|
|
484
160
|
|
|
485
|
-
The
|
|
486
|
-
- **`suggestion`** -- a plain-language instruction the agent follows to fix the violation
|
|
487
|
-
- **`fixStrategy`** -- a machine-readable fix description for the rule itself
|
|
488
|
-
- **`file`, `field`, `line`** -- exact location so the agent edits the right place
|
|
161
|
+
That's it. The agent will iterate automatically.
|
|
489
162
|
|
|
490
|
-
###
|
|
163
|
+
### What the agent sees
|
|
491
164
|
|
|
492
165
|
```bash
|
|
493
166
|
npx geo-lint --format=json
|
|
@@ -502,482 +175,71 @@ npx geo-lint --format=json
|
|
|
502
175
|
"severity": "warning",
|
|
503
176
|
"message": "Only 1/5 (20%) H2/H3 headings are question-formatted",
|
|
504
177
|
"suggestion": "Rephrase some headings as questions (e.g., 'How does X work?') to improve LLM snippet extraction."
|
|
505
|
-
},
|
|
506
|
-
{
|
|
507
|
-
"file": "blog/my-post",
|
|
508
|
-
"field": "body",
|
|
509
|
-
"rule": "geo-missing-table",
|
|
510
|
-
"severity": "warning",
|
|
511
|
-
"message": "No data table found in long-form content",
|
|
512
|
-
"suggestion": "Add a comparison table, feature matrix, or data summary table."
|
|
513
|
-
}
|
|
514
|
-
]
|
|
515
|
-
```
|
|
516
|
-
|
|
517
|
-
No ANSI colors. No human-friendly formatting. Pure structured data that any agent can parse and act on.
|
|
518
|
-
|
|
519
|
-
### Rule discovery (agent bootstrapping)
|
|
520
|
-
|
|
521
|
-
Before fixing anything, an agent can learn every rule and its fix strategy in one call:
|
|
522
|
-
|
|
523
|
-
```bash
|
|
524
|
-
npx geo-lint --rules
|
|
525
|
-
```
|
|
526
|
-
|
|
527
|
-
```json
|
|
528
|
-
[
|
|
529
|
-
{
|
|
530
|
-
"name": "geo-no-question-headings",
|
|
531
|
-
"severity": "warning",
|
|
532
|
-
"category": "geo",
|
|
533
|
-
"fixStrategy": "Rephrase some headings as questions (e.g., 'How does X work?')"
|
|
534
178
|
}
|
|
535
179
|
]
|
|
536
180
|
```
|
|
537
181
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
182
|
+
Every violation includes:
|
|
183
|
+
- **`suggestion`** -- plain-language fix instruction the agent follows directly
|
|
184
|
+
- **`fixStrategy`** -- machine-readable fix pattern for the rule
|
|
185
|
+
- **`file`, `field`, `line`** -- exact location so the agent edits the right place
|
|
541
186
|
|
|
542
|
-
|
|
543
|
-
## GEO Lint & Fix
|
|
544
|
-
|
|
545
|
-
1. Run `npx geo-lint --format=json` and capture output
|
|
546
|
-
2. Parse the JSON array of violations
|
|
547
|
-
3. Group violations by file
|
|
548
|
-
4. For each file:
|
|
549
|
-
- Read the MDX file
|
|
550
|
-
- For each violation, apply the fix described in `suggestion`
|
|
551
|
-
- Preserve the author's voice -- don't rewrite, restructure
|
|
552
|
-
5. Re-run `npx geo-lint --format=json`
|
|
553
|
-
6. If violations remain, repeat from step 4 (max 3 passes)
|
|
554
|
-
7. Report: files changed, violations fixed, any remaining issues
|
|
555
|
-
```
|
|
187
|
+
An empty array `[]` means zero violations -- the content is clean. The agent knows to stop.
|
|
556
188
|
|
|
557
|
-
|
|
189
|
+
See the full [Agent Integration Guide](docs/agent-integration.md) for per-agent setup, a Claude Code skill, and handling edge cases.
|
|
558
190
|
|
|
559
191
|
---
|
|
560
192
|
|
|
561
|
-
## Configuration
|
|
562
|
-
|
|
563
|
-
Configuration is loaded from `geo-lint.config.ts` (also supports `.mjs` and `.js`), or from a `geoLint` key in `package.json`.
|
|
193
|
+
## Configuration
|
|
564
194
|
|
|
565
|
-
|
|
195
|
+
Override any rule's severity or disable it entirely:
|
|
566
196
|
|
|
567
197
|
```typescript
|
|
568
198
|
import { defineConfig } from '@ijonis/geo-lint';
|
|
569
199
|
|
|
570
200
|
export default defineConfig({
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
// Content directories to scan (defaults shown)
|
|
575
|
-
contentPaths: [
|
|
576
|
-
{ dir: 'content/blog', type: 'blog', urlPrefix: '/blog/' },
|
|
577
|
-
{ dir: 'content/pages', type: 'page', urlPrefix: '/' },
|
|
578
|
-
{ dir: 'content/projects', type: 'project', urlPrefix: '/projects/' },
|
|
579
|
-
],
|
|
580
|
-
|
|
581
|
-
// Additional valid internal URLs for link validation
|
|
582
|
-
staticRoutes: ['/about', '/contact', '/pricing'],
|
|
583
|
-
|
|
584
|
-
// Directories to scan for image existence checks (default: ['public/images'])
|
|
585
|
-
imageDirectories: ['public/images'],
|
|
586
|
-
|
|
587
|
-
// Valid content categories (empty = skip category validation)
|
|
588
|
-
categories: ['engineering', 'design', 'business'],
|
|
589
|
-
|
|
590
|
-
// Slugs to exclude from linting
|
|
591
|
-
excludeSlugs: ['draft-post', 'test-page'],
|
|
592
|
-
|
|
593
|
-
// Content categories to exclude entirely (default: ['legal'])
|
|
594
|
-
excludeCategories: ['legal'],
|
|
595
|
-
|
|
596
|
-
// GEO-specific configuration
|
|
597
|
-
geo: {
|
|
598
|
-
brandName: 'ACME Corp', // Entity density check (empty = skip)
|
|
599
|
-
brandCity: 'Berlin', // Location entity check (empty = skip)
|
|
600
|
-
keywordsPath: '', // Reserved for future use
|
|
601
|
-
fillerPhrases: ['in this article', 'welcome to'], // Flagged in openings
|
|
602
|
-
extractionTriggers: ['key takeaway', 'in summary'], // Summary phrases
|
|
603
|
-
acronymAllowlist: ['HTML', 'CSS', 'API', 'SEO'], // Skip expansion check
|
|
604
|
-
vagueHeadings: ['introduction', 'overview'], // Generic headings
|
|
605
|
-
genericAuthorNames: ['admin', 'team'], // Flagged author names
|
|
606
|
-
allowedHtmlTags: ['Callout', 'Note'], // MDX components
|
|
607
|
-
},
|
|
608
|
-
|
|
609
|
-
// Per-rule severity overrides ('error' | 'warning' | 'off')
|
|
201
|
+
siteUrl: 'https://your-site.com',
|
|
202
|
+
contentPaths: [{ dir: 'content/blog', type: 'blog', urlPrefix: '/blog/' }],
|
|
610
203
|
rules: {
|
|
611
|
-
'geo-missing-table': 'off',
|
|
612
|
-
'orphan-content': 'error',
|
|
613
|
-
'title-approaching-limit': 'off', // Disable a rule
|
|
614
|
-
},
|
|
615
|
-
|
|
616
|
-
// Threshold overrides
|
|
617
|
-
thresholds: {
|
|
618
|
-
title: { minLength: 30, maxLength: 60, warnLength: 55 },
|
|
619
|
-
description: { minLength: 70, maxLength: 160, warnLength: 150 },
|
|
620
|
-
slug: { maxLength: 75 },
|
|
621
|
-
content: { minWordCount: 300, minReadabilityScore: 30 },
|
|
204
|
+
'geo-missing-table': 'off', // disable a rule
|
|
205
|
+
'orphan-content': 'error', // upgrade to error
|
|
622
206
|
},
|
|
623
207
|
});
|
|
624
208
|
```
|
|
625
209
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
| Option | Type | Required | Default | Description |
|
|
629
|
-
|--------|------|----------|---------|-------------|
|
|
630
|
-
| `siteUrl` | `string` | Yes | -- | Canonical site URL for link and canonical validation |
|
|
631
|
-
| `contentPaths` | `ContentPathConfig[]` | No | blog + pages + projects | Content directories to scan |
|
|
632
|
-
| `staticRoutes` | `string[]` | No | `[]` | Additional valid internal URLs |
|
|
633
|
-
| `imageDirectories` | `string[]` | No | `['public/images']` | Directories to scan for images |
|
|
634
|
-
| `categories` | `string[]` | No | `[]` | Valid content categories |
|
|
635
|
-
| `excludeSlugs` | `string[]` | No | `[]` | Slugs to skip during linting |
|
|
636
|
-
| `excludeCategories` | `string[]` | No | `['legal']` | Categories to skip entirely |
|
|
637
|
-
| `geo` | `GeoConfig` | No | `{}` | GEO entity density configuration |
|
|
638
|
-
| `rules` | `Record<string, Severity>` | No | `{}` | Per-rule severity overrides |
|
|
639
|
-
| `thresholds` | `ThresholdConfig` | No | See above | Length and quality thresholds |
|
|
640
|
-
|
|
641
|
-
### ContentPathConfig
|
|
642
|
-
|
|
643
|
-
```typescript
|
|
644
|
-
interface ContentPathConfig {
|
|
645
|
-
dir: string; // Relative path from project root
|
|
646
|
-
type: 'blog' | 'page' | 'project';
|
|
647
|
-
urlPrefix?: string; // URL prefix for permalink derivation
|
|
648
|
-
defaultLocale?: string; // Default locale when frontmatter has none
|
|
649
|
-
}
|
|
650
|
-
```
|
|
210
|
+
See the full [Configuration Reference](docs/configuration.md) for all options, thresholds, and GEO-specific settings.
|
|
651
211
|
|
|
652
212
|
---
|
|
653
213
|
|
|
654
|
-
##
|
|
655
|
-
|
|
656
|
-
By default, `@ijonis/geo-lint` scans `.md` and `.mdx` files with `gray-matter` frontmatter. **But you can lint any content source** -- Astro content collections, plain HTML, a headless CMS, a database -- by writing a small adapter that maps your content into `ContentItem` objects.
|
|
657
|
-
|
|
658
|
-
The adapter runs through the **programmatic API** (`lint()` / `lintQuiet()`), so you create a tiny wrapper script instead of calling the CLI directly. This takes ~20 lines for most setups.
|
|
214
|
+
## Extend It
|
|
659
215
|
|
|
660
|
-
###
|
|
216
|
+
### Custom Adapters
|
|
661
217
|
|
|
662
|
-
|
|
663
|
-
Your content (Astro, HTML, CMS, DB, …)
|
|
664
|
-
→ Adapter maps each page to a ContentItem
|
|
665
|
-
→ geo-lint runs all 92 rules against those items
|
|
666
|
-
→ JSON violations come back, agent fixes content
|
|
667
|
-
```
|
|
668
|
-
|
|
669
|
-
### The `ContentItem` contract
|
|
670
|
-
|
|
671
|
-
Every adapter must return an array of objects matching this interface. The required fields are what rules inspect:
|
|
672
|
-
|
|
673
|
-
```typescript
|
|
674
|
-
interface ContentItem {
|
|
675
|
-
// Required -- rules depend on these
|
|
676
|
-
title: string; // Page/post title (SEO title rules)
|
|
677
|
-
slug: string; // URL slug (slug validation rules)
|
|
678
|
-
description: string; // Meta description (description rules)
|
|
679
|
-
permalink: string; // Full URL path, e.g. '/blog/my-post' (link validation)
|
|
680
|
-
contentType: 'blog' | 'page' | 'project'; // Controls which rules apply
|
|
681
|
-
filePath: string; // Path to source file on disk (image path resolution)
|
|
682
|
-
rawContent: string; // Full file content including frontmatter/metadata
|
|
683
|
-
body: string; // Body content only (heading, readability, GEO rules)
|
|
684
|
-
|
|
685
|
-
// Optional -- unlocks additional rules when provided
|
|
686
|
-
image?: string; // Featured/OG image path
|
|
687
|
-
imageAlt?: string; // Image alt text
|
|
688
|
-
categories?: string[]; // Content categories
|
|
689
|
-
date?: string; // Publish date (freshness rules)
|
|
690
|
-
updatedAt?: string; // Last updated date
|
|
691
|
-
author?: string; // Author name (E-E-A-T rules)
|
|
692
|
-
locale?: string; // Locale code (i18n rules)
|
|
693
|
-
translationKey?: string; // Links translated versions
|
|
694
|
-
noindex?: boolean; // noindex flag
|
|
695
|
-
draft?: boolean; // Draft flag (skipped by default adapter)
|
|
696
|
-
}
|
|
697
|
-
```
|
|
698
|
-
|
|
699
|
-
> **Tip:** Provide as many optional fields as you can. Each one unlocks rules that would otherwise be silently skipped.
|
|
700
|
-
|
|
701
|
-
### Example: CMS / API adapter
|
|
218
|
+
Lint any content source -- Astro, HTML, a headless CMS -- by writing a small adapter:
|
|
702
219
|
|
|
703
220
|
```typescript
|
|
704
221
|
import { lint, createAdapter } from '@ijonis/geo-lint';
|
|
705
222
|
|
|
706
223
|
const adapter = createAdapter(async (projectRoot) => {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
return posts.map(post => ({
|
|
710
|
-
title: post.title,
|
|
711
|
-
slug: post.slug,
|
|
712
|
-
description: post.metaDescription,
|
|
713
|
-
permalink: `/blog/${post.slug}`,
|
|
714
|
-
body: post.markdownContent,
|
|
715
|
-
contentType: 'blog' as const,
|
|
716
|
-
filePath: `virtual/${post.slug}.mdx`,
|
|
717
|
-
rawContent: post.markdownContent,
|
|
718
|
-
image: post.featuredImage,
|
|
719
|
-
imageAlt: post.featuredImageAlt,
|
|
720
|
-
date: post.publishedAt,
|
|
721
|
-
locale: post.language,
|
|
722
|
-
categories: post.tags,
|
|
723
|
-
}));
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
const exitCode = await lint({ adapter });
|
|
727
|
-
process.exit(exitCode);
|
|
728
|
-
```
|
|
729
|
-
|
|
730
|
-
### Example: Astro content collections
|
|
731
|
-
|
|
732
|
-
Astro stores content in `src/content/` with its own frontmatter schema. Write an adapter that reads the `.md`/`.mdx` files and maps Astro's frontmatter fields to `ContentItem`:
|
|
733
|
-
|
|
734
|
-
```typescript
|
|
735
|
-
// scripts/lint.ts
|
|
736
|
-
import { lint, createAdapter } from '@ijonis/geo-lint';
|
|
737
|
-
import { readFileSync, readdirSync } from 'fs';
|
|
738
|
-
import { join, basename } from 'path';
|
|
739
|
-
import matter from 'gray-matter';
|
|
740
|
-
|
|
741
|
-
const adapter = createAdapter((projectRoot) => {
|
|
742
|
-
const contentDir = join(projectRoot, 'src/content/blog');
|
|
743
|
-
const files = readdirSync(contentDir).filter(f => f.endsWith('.md') || f.endsWith('.mdx'));
|
|
744
|
-
|
|
745
|
-
return files.map(file => {
|
|
746
|
-
const filePath = join(contentDir, file);
|
|
747
|
-
const raw = readFileSync(filePath, 'utf-8');
|
|
748
|
-
const { data: fm, content: body } = matter(raw);
|
|
749
|
-
const slug = fm.slug ?? basename(file, '.mdx').replace(/\.md$/, '');
|
|
750
|
-
|
|
751
|
-
return {
|
|
752
|
-
title: fm.title ?? '',
|
|
753
|
-
slug,
|
|
754
|
-
description: fm.description ?? '',
|
|
755
|
-
permalink: `/blog/${slug}`,
|
|
756
|
-
contentType: 'blog' as const,
|
|
757
|
-
filePath,
|
|
758
|
-
rawContent: raw,
|
|
759
|
-
body,
|
|
760
|
-
image: fm.heroImage ?? fm.image,
|
|
761
|
-
imageAlt: fm.heroImageAlt ?? fm.imageAlt,
|
|
762
|
-
date: fm.pubDate ?? fm.date,
|
|
763
|
-
updatedAt: fm.updatedDate,
|
|
764
|
-
author: fm.author,
|
|
765
|
-
categories: fm.tags ?? fm.categories,
|
|
766
|
-
draft: fm.draft,
|
|
767
|
-
};
|
|
768
|
-
});
|
|
224
|
+
// Map your content into ContentItem objects
|
|
225
|
+
return [{ title, slug, description, body, permalink, contentType, filePath, rawContent }];
|
|
769
226
|
});
|
|
770
227
|
|
|
771
|
-
|
|
772
|
-
adapter,
|
|
773
|
-
projectRoot: process.cwd(),
|
|
774
|
-
format: 'json',
|
|
775
|
-
});
|
|
776
|
-
process.exit(exitCode);
|
|
777
|
-
```
|
|
778
|
-
|
|
779
|
-
Run it with:
|
|
780
|
-
|
|
781
|
-
```bash
|
|
782
|
-
npx tsx scripts/lint.ts
|
|
783
|
-
```
|
|
784
|
-
|
|
785
|
-
### Example: Static HTML site
|
|
786
|
-
|
|
787
|
-
For a static site with plain `.html` files (no frontmatter), extract metadata from `<title>`, `<meta>` tags, and the document body. A lightweight parser like `cheerio` does the job:
|
|
788
|
-
|
|
789
|
-
```typescript
|
|
790
|
-
// scripts/lint.ts
|
|
791
|
-
import { lint, createAdapter } from '@ijonis/geo-lint';
|
|
792
|
-
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
793
|
-
import { join, relative, basename } from 'path';
|
|
794
|
-
import * as cheerio from 'cheerio';
|
|
795
|
-
|
|
796
|
-
function findHtmlFiles(dir: string): string[] {
|
|
797
|
-
const results: string[] = [];
|
|
798
|
-
for (const entry of readdirSync(dir)) {
|
|
799
|
-
const full = join(dir, entry);
|
|
800
|
-
if (statSync(full).isDirectory()) results.push(...findHtmlFiles(full));
|
|
801
|
-
else if (entry.endsWith('.html')) results.push(full);
|
|
802
|
-
}
|
|
803
|
-
return results;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
const adapter = createAdapter((projectRoot) => {
|
|
807
|
-
const htmlFiles = findHtmlFiles(projectRoot);
|
|
808
|
-
|
|
809
|
-
return htmlFiles.map(filePath => {
|
|
810
|
-
const raw = readFileSync(filePath, 'utf-8');
|
|
811
|
-
const $ = cheerio.load(raw);
|
|
812
|
-
|
|
813
|
-
const title = $('title').text() || '';
|
|
814
|
-
const description = $('meta[name="description"]').attr('content') || '';
|
|
815
|
-
const ogImage = $('meta[property="og:image"]').attr('content');
|
|
816
|
-
const ogImageAlt = $('meta[property="og:image:alt"]').attr('content');
|
|
817
|
-
const author = $('meta[name="author"]').attr('content');
|
|
818
|
-
const body = $('main').html() ?? $('body').html() ?? '';
|
|
819
|
-
const rel = relative(projectRoot, filePath);
|
|
820
|
-
const slug = rel.replace(/\.html$/, '').replace(/\/index$/, '');
|
|
821
|
-
|
|
822
|
-
return {
|
|
823
|
-
title,
|
|
824
|
-
slug,
|
|
825
|
-
description,
|
|
826
|
-
permalink: `/${slug}`,
|
|
827
|
-
contentType: 'page' as const,
|
|
828
|
-
filePath,
|
|
829
|
-
rawContent: raw,
|
|
830
|
-
body,
|
|
831
|
-
image: ogImage,
|
|
832
|
-
imageAlt: ogImageAlt,
|
|
833
|
-
author,
|
|
834
|
-
};
|
|
835
|
-
});
|
|
836
|
-
});
|
|
837
|
-
|
|
838
|
-
const exitCode = await lint({
|
|
839
|
-
adapter,
|
|
840
|
-
projectRoot: process.cwd(),
|
|
841
|
-
format: 'json',
|
|
842
|
-
});
|
|
843
|
-
process.exit(exitCode);
|
|
844
|
-
```
|
|
845
|
-
|
|
846
|
-
### Example: Astro `.astro` component pages
|
|
847
|
-
|
|
848
|
-
For `.astro` files that use embedded frontmatter (the `---` block at the top), extract the variables and template body:
|
|
849
|
-
|
|
850
|
-
```typescript
|
|
851
|
-
// scripts/lint.ts
|
|
852
|
-
import { lint, createAdapter } from '@ijonis/geo-lint';
|
|
853
|
-
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
854
|
-
import { join, relative } from 'path';
|
|
855
|
-
|
|
856
|
-
function findAstroFiles(dir: string): string[] {
|
|
857
|
-
const results: string[] = [];
|
|
858
|
-
for (const entry of readdirSync(dir)) {
|
|
859
|
-
const full = join(dir, entry);
|
|
860
|
-
if (statSync(full).isDirectory()) results.push(...findAstroFiles(full));
|
|
861
|
-
else if (entry.endsWith('.astro')) results.push(full);
|
|
862
|
-
}
|
|
863
|
-
return results;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
function parseAstroFrontmatter(raw: string): Record<string, string> {
|
|
867
|
-
const match = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
868
|
-
if (!match) return {};
|
|
869
|
-
const vars: Record<string, string> = {};
|
|
870
|
-
for (const line of match[1].split('\n')) {
|
|
871
|
-
const assign = line.match(/(?:const|let)\s+(\w+)\s*=\s*['"](.+?)['"]/);
|
|
872
|
-
if (assign) vars[assign[1]] = assign[2];
|
|
873
|
-
}
|
|
874
|
-
return vars;
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
const adapter = createAdapter((projectRoot) => {
|
|
878
|
-
const pagesDir = join(projectRoot, 'src/pages');
|
|
879
|
-
const files = findAstroFiles(pagesDir);
|
|
880
|
-
|
|
881
|
-
return files.map(filePath => {
|
|
882
|
-
const raw = readFileSync(filePath, 'utf-8');
|
|
883
|
-
const vars = parseAstroFrontmatter(raw);
|
|
884
|
-
const templateBody = raw.replace(/^---[\s\S]*?---/, '').trim();
|
|
885
|
-
const rel = relative(pagesDir, filePath);
|
|
886
|
-
const slug = rel.replace(/\.astro$/, '').replace(/\/index$/, '');
|
|
887
|
-
|
|
888
|
-
return {
|
|
889
|
-
title: vars.title ?? '',
|
|
890
|
-
slug,
|
|
891
|
-
description: vars.description ?? '',
|
|
892
|
-
permalink: `/${slug}`,
|
|
893
|
-
contentType: 'page' as const,
|
|
894
|
-
filePath,
|
|
895
|
-
rawContent: raw,
|
|
896
|
-
body: templateBody,
|
|
897
|
-
image: vars.ogImage,
|
|
898
|
-
author: vars.author,
|
|
899
|
-
};
|
|
900
|
-
});
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
const exitCode = await lint({
|
|
904
|
-
adapter,
|
|
905
|
-
projectRoot: process.cwd(),
|
|
906
|
-
format: 'json',
|
|
907
|
-
});
|
|
908
|
-
process.exit(exitCode);
|
|
909
|
-
```
|
|
910
|
-
|
|
911
|
-
### Tips for custom adapters
|
|
912
|
-
|
|
913
|
-
| Topic | Guidance |
|
|
914
|
-
|-------|----------|
|
|
915
|
-
| **`filePath` must be a real path** | Rules like `image-not-found` resolve image paths relative to `filePath`. Use the actual file path on disk, not a virtual one, whenever possible. |
|
|
916
|
-
| **`body` should be the renderable content** | Strip frontmatter, script blocks, and layout wrappers. Rules analyze headings, paragraphs, and links in the body. |
|
|
917
|
-
| **`rawContent` includes everything** | Some rules inspect the full file (frontmatter + body). Always pass the unmodified file content. |
|
|
918
|
-
| **`contentType` controls rule selection** | `'blog'` triggers date/author/category rules. `'page'` and `'project'` are lighter. Map your content to the closest match. |
|
|
919
|
-
| **Config still applies** | Your `geo-lint.config.ts` settings (`siteUrl`, `categories`, `imageDirectories`, `rules`, etc.) still apply. Only `contentPaths` is bypassed by the adapter. |
|
|
920
|
-
| **Combine with the default adapter** | You can lint MDX files via `contentPaths` in config AND additional content via a custom adapter in separate runs. |
|
|
921
|
-
|
|
922
|
-
### Let an AI agent write the adapter for you
|
|
923
|
-
|
|
924
|
-
If you're integrating geo-lint into a project that uses a non-standard content format, you can ask your AI agent to generate the adapter. Give it this prompt:
|
|
925
|
-
|
|
228
|
+
await lint({ adapter });
|
|
926
229
|
```
|
|
927
|
-
I want to lint my content with @ijonis/geo-lint but my site uses [Astro/HTML/Nuxt/etc.].
|
|
928
|
-
Create a scripts/lint.ts file with a custom adapter that:
|
|
929
|
-
1. Finds all content files in [describe your content directory]
|
|
930
|
-
2. Extracts title, description, slug, body from [describe your format]
|
|
931
|
-
3. Maps them to ContentItem objects
|
|
932
|
-
4. Runs lint() with JSON output
|
|
933
|
-
|
|
934
|
-
See the Custom Adapters section in the @ijonis/geo-lint README for the ContentItem interface
|
|
935
|
-
and examples. Use createAdapter() from '@ijonis/geo-lint'.
|
|
936
|
-
```
|
|
937
|
-
|
|
938
|
-
The agent will read your project structure, create the adapter, run it, and fix any violations it finds -- the standard agentic lint-fix loop works the same regardless of the content format.
|
|
939
230
|
|
|
940
|
-
|
|
231
|
+
See the [Custom Adapters Guide](docs/custom-adapters.md) for the full `ContentItem` interface and ready-to-use examples for Astro, HTML, and CMS sources.
|
|
941
232
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
Use `lint()` for full output or `lintQuiet()` for raw results without console output:
|
|
233
|
+
### Programmatic API
|
|
945
234
|
|
|
946
235
|
```typescript
|
|
947
236
|
import { lint, lintQuiet } from '@ijonis/geo-lint';
|
|
948
237
|
|
|
949
|
-
|
|
950
|
-
const
|
|
951
|
-
projectRoot: './my-project',
|
|
952
|
-
format: 'json',
|
|
953
|
-
});
|
|
954
|
-
|
|
955
|
-
// Quiet mode: returns raw LintResult[] with no console output
|
|
956
|
-
const results = await lintQuiet({
|
|
957
|
-
projectRoot: './my-project',
|
|
958
|
-
});
|
|
959
|
-
|
|
960
|
-
// Filter and process results programmatically
|
|
961
|
-
const geoViolations = results.filter(r => r.rule.startsWith('geo-'));
|
|
962
|
-
const errors = results.filter(r => r.severity === 'error');
|
|
963
|
-
|
|
964
|
-
console.log(`${geoViolations.length} GEO issues found`);
|
|
965
|
-
console.log(`${errors.length} errors (will block build)`);
|
|
238
|
+
const exitCode = await lint({ format: 'json' }); // with console output
|
|
239
|
+
const results = await lintQuiet({ projectRoot: '.' }); // raw LintResult[]
|
|
966
240
|
```
|
|
967
241
|
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
```typescript
|
|
971
|
-
interface LintResult {
|
|
972
|
-
file: string; // Relative path (e.g., "blog/my-post")
|
|
973
|
-
field: string; // Field checked (e.g., "title", "body", "image")
|
|
974
|
-
rule: string; // Rule identifier (e.g., "geo-no-question-headings")
|
|
975
|
-
severity: 'error' | 'warning';
|
|
976
|
-
message: string; // Human-readable violation description
|
|
977
|
-
suggestion?: string; // Actionable fix suggestion
|
|
978
|
-
line?: number; // Line number in source file (when applicable)
|
|
979
|
-
}
|
|
980
|
-
```
|
|
242
|
+
See the [API Reference](docs/api.md) for all options and types.
|
|
981
243
|
|
|
982
244
|
---
|
|
983
245
|
|
|
@@ -999,6 +261,10 @@ Options:
|
|
|
999
261
|
|
|
1000
262
|
---
|
|
1001
263
|
|
|
264
|
+
## Contributing
|
|
265
|
+
|
|
266
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, testing, and how to add new rules. Changes are tracked in the [CHANGELOG](CHANGELOG.md).
|
|
267
|
+
|
|
1002
268
|
## License
|
|
1003
269
|
|
|
1004
270
|
[MIT](LICENSE)
|