@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/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
+ [![npm version](https://img.shields.io/npm/v/@ijonis/geo-lint)](https://www.npmjs.com/package/@ijonis/geo-lint)
6
+ [![CI](https://img.shields.io/github/actions/workflow/status/ijonis/geo-lint/ci.yml?branch=main&label=CI)](https://github.com/IJONIS/geo-lint/actions)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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.