@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 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
- - Initial release with 55 SEO/GEO rules
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 -- 46 SEO rules and **7 dedicated GEO rules** that have zero open-source alternatives.
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
- ## The 7 GEO Rules
64
+ ## GEO Rules
65
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.
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 53 rules across 5 categories. Here is a summary:
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 | 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) |
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 (2 rules)**
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 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:
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
- The adapter receives the project root path and must return an array of `ContentItem` objects. All standard rules run against the returned items.
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