@ijonis/geo-lint 0.1.0 → 0.1.1
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 -1
- package/README.md +331 -15
- package/dist/cli.cjs +14081 -895
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +14081 -895
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +14084 -896
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -81
- package/dist/index.d.ts +114 -81
- package/dist/index.js +14085 -897
- package/dist/index.js.map +1 -1
- package/package.json +1 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,10 +8,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
|
-
-
|
|
11
|
+
- 28 new GEO rules across 4 categories (total: 35 GEO rules, 81 rules overall)
|
|
12
|
+
- **E-E-A-T (8 rules):** source citations, expert quotes, author validation, heading quality, FAQ quality, definition patterns, how-to steps, TL;DR detection
|
|
13
|
+
- **Structure (7 rules):** section length, paragraph length, list presence, citation block bounds, orphaned intros, heading density, structural element ratio
|
|
14
|
+
- **Freshness (7 rules):** stale year references, outdated content, passive voice, sentence length, internal links, comparison tables, inline HTML
|
|
15
|
+
- **RAG Optimization (6 rules):** extraction triggers, section self-containment, vague openings, acronym expansion, statistic context, summary sections
|
|
16
|
+
- `author` field support in ContentItem and MDX adapter
|
|
17
|
+
- 6 new GeoConfig options: `fillerPhrases`, `extractionTriggers`, `acronymAllowlist`, `vagueHeadings`, `genericAuthorNames`, `allowedHtmlTags`
|
|
18
|
+
- New utility module `geo-advanced-analyzer.ts` with 10 analysis functions
|
|
19
|
+
- Extended `geo-analyzer.ts` with 6 new utility functions
|
|
20
|
+
- Comprehensive tests for all 28 new rules (~120 tests)
|
|
21
|
+
|
|
22
|
+
## [0.1.0] - 2026-02-18
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- Initial release with 53 SEO/GEO rules
|
|
12
26
|
- 7 GEO (Generative Engine Optimization) rules for AI search visibility
|
|
13
27
|
- Configurable via `geo-lint.config.ts`
|
|
14
28
|
- JSON output mode for AI agent integration
|
|
15
29
|
- Fix strategies for every rule (agent-readable)
|
|
16
30
|
- MDX/Markdown content adapter with `gray-matter`
|
|
17
31
|
- CLI with `--format=json`, `--rules`, `--root`, `--config` flags
|
|
32
|
+
|
|
33
|
+
[0.1.0]: https://github.com/IJONIS/geo-lint/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ This works today with Claude Code, Cursor, Windsurf, Copilot, or any agent that
|
|
|
26
26
|
|
|
27
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
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 --
|
|
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.
|
|
30
30
|
|
|
31
31
|
---
|
|
32
32
|
|
|
@@ -61,9 +61,13 @@ Or let your agent handle it -- see [Agent Integration](#agent-integration) below
|
|
|
61
61
|
|
|
62
62
|
---
|
|
63
63
|
|
|
64
|
-
##
|
|
64
|
+
## GEO Rules
|
|
65
65
|
|
|
66
|
-
No other open-source linter checks for these.
|
|
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.
|
|
69
|
+
|
|
70
|
+
### Core GEO Rules (7 rules)
|
|
67
71
|
|
|
68
72
|
### 1. `geo-no-question-headings`
|
|
69
73
|
|
|
@@ -222,15 +226,15 @@ headquarters, using modern frameworks and cloud infrastructure.
|
|
|
222
226
|
|
|
223
227
|
## All Rules
|
|
224
228
|
|
|
225
|
-
`@ijonis/geo-lint` ships with
|
|
229
|
+
`@ijonis/geo-lint` ships with 92 rules across 5 categories. Here is a summary:
|
|
226
230
|
|
|
227
231
|
| Category | Rules | Severity Mix | Focus |
|
|
228
232
|
|----------|-------|-------------|-------|
|
|
229
|
-
| SEO |
|
|
230
|
-
| Content |
|
|
231
|
-
| Technical |
|
|
232
|
-
| i18n |
|
|
233
|
-
| GEO |
|
|
233
|
+
| SEO | 32 | 6 errors, 26 warnings | Titles, descriptions, headings, slugs, OG images, canonical URLs, keywords, links, schema |
|
|
234
|
+
| Content | 14 | 2 errors, 12 warnings | Word count, readability, dates, categories, jargon density, repetition, vocabulary diversity, transition words, sentence variety |
|
|
235
|
+
| Technical | 8 | 3 errors, 5 warnings | Broken links, image files, trailing slashes, external URLs, performance |
|
|
236
|
+
| i18n | 3 | 0 errors, 3 warnings | Translation pairs, locale metadata |
|
|
237
|
+
| GEO | 35 | 0 errors, 35 warnings | AI citation readiness: E-E-A-T signals, content structure, freshness, RAG optimization |
|
|
234
238
|
|
|
235
239
|
<details>
|
|
236
240
|
<summary>Full rule list</summary>
|
|
@@ -347,12 +351,19 @@ headquarters, using modern frameworks and cloud infrastructure.
|
|
|
347
351
|
|------|----------|-------------|
|
|
348
352
|
| `orphan-content` | warning | Content should be linked from at least one other page |
|
|
349
353
|
|
|
350
|
-
**Content Quality (
|
|
354
|
+
**Content Quality (14 rules)**
|
|
351
355
|
|
|
352
356
|
| Rule | Severity | Description |
|
|
353
357
|
|------|----------|-------------|
|
|
354
358
|
| `content-too-short` | warning | Content should meet minimum word count (300) |
|
|
355
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) |
|
|
356
367
|
|
|
357
368
|
**Date Validation (3 rules)**
|
|
358
369
|
|
|
@@ -376,7 +387,7 @@ headquarters, using modern frameworks and cloud infrastructure.
|
|
|
376
387
|
| `translation-pair-missing` | warning | Translated content should have both language versions |
|
|
377
388
|
| `missing-locale` | warning | Content should have a locale field |
|
|
378
389
|
|
|
379
|
-
**GEO (7 rules)**
|
|
390
|
+
**GEO — Core (7 rules)**
|
|
380
391
|
|
|
381
392
|
| Rule | Severity | Description |
|
|
382
393
|
|------|----------|-------------|
|
|
@@ -388,6 +399,54 @@ headquarters, using modern frameworks and cloud infrastructure.
|
|
|
388
399
|
| `geo-short-citation-blocks` | warning | Section lead paragraphs should be 40+ words |
|
|
389
400
|
| `geo-low-entity-density` | warning | Brand and location should appear in content |
|
|
390
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
|
+
|
|
391
450
|
</details>
|
|
392
451
|
|
|
393
452
|
---
|
|
@@ -539,6 +598,12 @@ export default defineConfig({
|
|
|
539
598
|
brandName: 'ACME Corp', // Entity density check (empty = skip)
|
|
540
599
|
brandCity: 'Berlin', // Location entity check (empty = skip)
|
|
541
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
|
|
542
607
|
},
|
|
543
608
|
|
|
544
609
|
// Per-rule severity overrides ('error' | 'warning' | 'off')
|
|
@@ -588,13 +653,57 @@ interface ContentPathConfig {
|
|
|
588
653
|
|
|
589
654
|
## Custom Adapters
|
|
590
655
|
|
|
591
|
-
By default, `@ijonis/geo-lint` scans
|
|
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.
|
|
659
|
+
|
|
660
|
+
### How it works
|
|
661
|
+
|
|
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
|
|
592
702
|
|
|
593
703
|
```typescript
|
|
594
704
|
import { lint, createAdapter } from '@ijonis/geo-lint';
|
|
595
705
|
|
|
596
706
|
const adapter = createAdapter(async (projectRoot) => {
|
|
597
|
-
// Fetch from your CMS, database, or API
|
|
598
707
|
const posts = await fetchFromCMS();
|
|
599
708
|
|
|
600
709
|
return posts.map(post => ({
|
|
@@ -606,7 +715,6 @@ const adapter = createAdapter(async (projectRoot) => {
|
|
|
606
715
|
contentType: 'blog' as const,
|
|
607
716
|
filePath: `virtual/${post.slug}.mdx`,
|
|
608
717
|
rawContent: post.markdownContent,
|
|
609
|
-
// Optional fields
|
|
610
718
|
image: post.featuredImage,
|
|
611
719
|
imageAlt: post.featuredImageAlt,
|
|
612
720
|
date: post.publishedAt,
|
|
@@ -619,7 +727,215 @@ const exitCode = await lint({ adapter });
|
|
|
619
727
|
process.exit(exitCode);
|
|
620
728
|
```
|
|
621
729
|
|
|
622
|
-
|
|
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
|
+
});
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
const exitCode = await lint({
|
|
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
|
+
|
|
926
|
+
```
|
|
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.
|
|
623
939
|
|
|
624
940
|
---
|
|
625
941
|
|