@ijonis/geo-lint 0.1.2 → 0.1.4
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 +5 -3
- package/dist/cli.cjs +68 -35
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +68 -35
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +70 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +70 -36
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,17 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.2] - 2026-02-20
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `--version` flag was hardcoded to `0.1.0` -- now reads dynamically from package.json
|
|
14
|
+
- `--rules` flag crashed without a config file -- now falls back to defaults so users can discover all 92 rules without project setup
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Polished README: 1008 lines → 259 lines with stronger GEO and agentic workflow framing
|
|
18
|
+
- Split reference documentation into 7 dedicated docs/ files (rules, GEO examples, configuration, custom adapters, API, agent integration)
|
|
19
|
+
- Added copy-paste agent prompts for Claude Code, Cursor, Windsurf, and Copilot
|
|
20
|
+
- Updated GitHub repo description, homepage, and topic tags
|
|
21
|
+
|
|
22
|
+
## [0.1.1] - 2026-02-19
|
|
23
|
+
|
|
10
24
|
### Added
|
|
11
|
-
- 28 new GEO rules across 4 categories (total: 35 GEO rules,
|
|
25
|
+
- 28 new GEO rules across 4 categories (total: 35 GEO rules, 92 rules overall)
|
|
12
26
|
- **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
27
|
- **Structure (7 rules):** section length, paragraph length, list presence, citation block bounds, orphaned intros, heading density, structural element ratio
|
|
14
28
|
- **Freshness (7 rules):** stale year references, outdated content, passive voice, sentence length, internal links, comparison tables, inline HTML
|
|
15
29
|
- **RAG Optimization (6 rules):** extraction triggers, section self-containment, vague openings, acronym expansion, statistic context, summary sections
|
|
30
|
+
- 14 content quality rules including readability analysis inspired by Yoast SEO: transition words, consecutive sentence starts, sentence length variety, vocabulary diversity, jargon density
|
|
16
31
|
- `author` field support in ContentItem and MDX adapter
|
|
17
32
|
- 6 new GeoConfig options: `fillerPhrases`, `extractionTriggers`, `acronymAllowlist`, `vagueHeadings`, `genericAuthorNames`, `allowedHtmlTags`
|
|
18
|
-
-
|
|
19
|
-
- Extended `geo-analyzer.ts` with 6 new utility functions
|
|
20
|
-
- Comprehensive tests for all 28 new rules (~120 tests)
|
|
33
|
+
- Comprehensive tests for all new rules (~120 tests)
|
|
21
34
|
|
|
22
35
|
## [0.1.0] - 2026-02-18
|
|
23
36
|
|
|
@@ -30,4 +43,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
30
43
|
- MDX/Markdown content adapter with `gray-matter`
|
|
31
44
|
- CLI with `--format=json`, `--rules`, `--root`, `--config` flags
|
|
32
45
|
|
|
46
|
+
[0.1.2]: https://github.com/IJONIS/geo-lint/releases/tag/v0.1.2
|
|
47
|
+
[0.1.1]: https://github.com/IJONIS/geo-lint/releases/tag/v0.1.1
|
|
33
48
|
[0.1.0]: https://github.com/IJONIS/geo-lint/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
|
|
13
13
|
## Why this exists
|
|
14
14
|
|
|
15
|
-
|
|
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.
|
|
16
16
|
|
|
17
|
-
|
|
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.
|
|
18
18
|
|
|
19
|
-
|
|
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.
|
|
20
|
+
|
|
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.
|
|
20
22
|
|
|
21
23
|
---
|
|
22
24
|
|
package/dist/cli.cjs
CHANGED
|
@@ -448,7 +448,7 @@ function createLinkExtractor(siteUrl) {
|
|
|
448
448
|
// src/utils/slug-resolver.ts
|
|
449
449
|
var import_node_fs4 = require("fs");
|
|
450
450
|
var import_node_path3 = require("path");
|
|
451
|
-
function extractSlugFromFile(filePath) {
|
|
451
|
+
function extractSlugFromFile(filePath, defaultLocale = "de") {
|
|
452
452
|
try {
|
|
453
453
|
const content = (0, import_node_fs4.readFileSync)(filePath, "utf-8");
|
|
454
454
|
if (!content.startsWith("---")) return null;
|
|
@@ -462,7 +462,7 @@ function extractSlugFromFile(filePath) {
|
|
|
462
462
|
if (draftMatch) return null;
|
|
463
463
|
return {
|
|
464
464
|
slug: slugMatch[1].trim(),
|
|
465
|
-
locale: localeMatch ? localeMatch[1].trim() :
|
|
465
|
+
locale: localeMatch ? localeMatch[1].trim() : defaultLocale
|
|
466
466
|
};
|
|
467
467
|
} catch {
|
|
468
468
|
return null;
|
|
@@ -491,12 +491,12 @@ function scanRawContentPermalinks(knownSlugs, contentPaths) {
|
|
|
491
491
|
const contentDir = (0, import_node_path3.join)(projectRoot, pathConfig.dir);
|
|
492
492
|
if (!(0, import_node_fs4.existsSync)(contentDir)) continue;
|
|
493
493
|
const urlPrefix = pathConfig.urlPrefix ?? "/";
|
|
494
|
+
const defaultLocale = pathConfig.defaultLocale ?? "de";
|
|
494
495
|
for (const file of findFilesInDir(contentDir, ".mdx")) {
|
|
495
|
-
const meta = extractSlugFromFile(file);
|
|
496
|
+
const meta = extractSlugFromFile(file, defaultLocale);
|
|
496
497
|
if (!meta) continue;
|
|
497
498
|
let permalink;
|
|
498
|
-
|
|
499
|
-
if (meta.locale !== defaultLocale && meta.locale !== "de") {
|
|
499
|
+
if (meta.locale !== defaultLocale) {
|
|
500
500
|
permalink = `/${meta.locale}${urlPrefix}${meta.slug}`.replace(/\/+/g, "/");
|
|
501
501
|
} else {
|
|
502
502
|
permalink = `${urlPrefix}${meta.slug}`.replace(/\/+/g, "/");
|
|
@@ -1221,7 +1221,7 @@ function countSentences(text) {
|
|
|
1221
1221
|
}
|
|
1222
1222
|
|
|
1223
1223
|
// src/utils/readability.ts
|
|
1224
|
-
function estimateSyllables(word) {
|
|
1224
|
+
function estimateSyllables(word, locale = "de") {
|
|
1225
1225
|
const lower = word.toLowerCase();
|
|
1226
1226
|
const vowelPattern = /[aeiouyäöü]+/gi;
|
|
1227
1227
|
const matches = lower.match(vowelPattern);
|
|
@@ -1229,17 +1229,21 @@ function estimateSyllables(word) {
|
|
|
1229
1229
|
return 1;
|
|
1230
1230
|
}
|
|
1231
1231
|
let count = matches.length;
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1232
|
+
const lang = locale.toLowerCase().slice(0, 2);
|
|
1233
|
+
if (lang === "en") {
|
|
1234
|
+
if (lower.endsWith("e") && count > 1) {
|
|
1235
|
+
count -= 0.5;
|
|
1236
|
+
}
|
|
1237
|
+
} else {
|
|
1238
|
+
if (word.length > 12) {
|
|
1239
|
+
count = Math.max(count, Math.ceil(word.length / 4));
|
|
1240
|
+
}
|
|
1237
1241
|
}
|
|
1238
1242
|
return Math.max(1, Math.round(count));
|
|
1239
1243
|
}
|
|
1240
|
-
function countSyllables(text) {
|
|
1244
|
+
function countSyllables(text, locale) {
|
|
1241
1245
|
const words = text.split(/\s+/).filter((word) => word.length > 0).filter((word) => /\w/.test(word));
|
|
1242
|
-
return words.reduce((total, word) => total + estimateSyllables(word), 0);
|
|
1246
|
+
return words.reduce((total, word) => total + estimateSyllables(word, locale), 0);
|
|
1243
1247
|
}
|
|
1244
1248
|
function averageWordLength(text) {
|
|
1245
1249
|
const words = text.split(/\s+/).filter((word) => word.length > 0).filter((word) => /\w/.test(word));
|
|
@@ -1247,11 +1251,35 @@ function averageWordLength(text) {
|
|
|
1247
1251
|
const totalLength = words.reduce((sum, word) => sum + word.length, 0);
|
|
1248
1252
|
return totalLength / words.length;
|
|
1249
1253
|
}
|
|
1250
|
-
|
|
1254
|
+
var FLESCH_COEFFICIENTS = {
|
|
1255
|
+
en: { intercept: 206.835, aslWeight: 1.015, aswWeight: 84.6 },
|
|
1256
|
+
de: { intercept: 180, aslWeight: 1, aswWeight: 58.5 }
|
|
1257
|
+
};
|
|
1258
|
+
var INTERPRETATION_BANDS = {
|
|
1259
|
+
en: [
|
|
1260
|
+
{ min: 90, label: "Very easy to read" },
|
|
1261
|
+
{ min: 80, label: "Easy to read" },
|
|
1262
|
+
{ min: 70, label: "Fairly easy to read" },
|
|
1263
|
+
{ min: 60, label: "Standard" },
|
|
1264
|
+
{ min: 50, label: "Fairly difficult" },
|
|
1265
|
+
{ min: 30, label: "Difficult" },
|
|
1266
|
+
{ min: 0, label: "Very difficult" }
|
|
1267
|
+
],
|
|
1268
|
+
de: [
|
|
1269
|
+
{ min: 70, label: "Very easy to read" },
|
|
1270
|
+
{ min: 60, label: "Easy to read" },
|
|
1271
|
+
{ min: 50, label: "Fairly easy to read" },
|
|
1272
|
+
{ min: 40, label: "Standard" },
|
|
1273
|
+
{ min: 30, label: "Fairly difficult" },
|
|
1274
|
+
{ min: 20, label: "Difficult" },
|
|
1275
|
+
{ min: 0, label: "Very difficult" }
|
|
1276
|
+
]
|
|
1277
|
+
};
|
|
1278
|
+
function calculateReadability(mdxBody, locale = "de") {
|
|
1251
1279
|
const plainText = stripMarkdown(mdxBody);
|
|
1252
1280
|
const wordCount = countWords(mdxBody);
|
|
1253
1281
|
const sentenceCount = countSentences(mdxBody);
|
|
1254
|
-
const syllableCount = countSyllables(plainText);
|
|
1282
|
+
const syllableCount = countSyllables(plainText, locale);
|
|
1255
1283
|
if (wordCount === 0 || sentenceCount === 0) {
|
|
1256
1284
|
return {
|
|
1257
1285
|
score: 0,
|
|
@@ -1264,23 +1292,26 @@ function calculateReadability(mdxBody) {
|
|
|
1264
1292
|
const avgSentenceLength = wordCount / sentenceCount;
|
|
1265
1293
|
const avgSyllablesPerWord = syllableCount / wordCount;
|
|
1266
1294
|
const avgWordLen = averageWordLength(plainText);
|
|
1267
|
-
const
|
|
1295
|
+
const lang = locale.toLowerCase().slice(0, 2);
|
|
1296
|
+
const coefficients = FLESCH_COEFFICIENTS[lang] ?? FLESCH_COEFFICIENTS.de;
|
|
1297
|
+
const score = Math.round(
|
|
1298
|
+
coefficients.intercept - coefficients.aslWeight * avgSentenceLength - coefficients.aswWeight * avgSyllablesPerWord
|
|
1299
|
+
);
|
|
1268
1300
|
const clampedScore = Math.max(0, Math.min(100, score));
|
|
1269
1301
|
return {
|
|
1270
1302
|
score: clampedScore,
|
|
1271
1303
|
avgSentenceLength: Math.round(avgSentenceLength * 10) / 10,
|
|
1272
1304
|
avgSyllablesPerWord: Math.round(avgSyllablesPerWord * 100) / 100,
|
|
1273
1305
|
avgWordLength: Math.round(avgWordLen * 10) / 10,
|
|
1274
|
-
interpretation: getInterpretation(clampedScore)
|
|
1306
|
+
interpretation: getInterpretation(clampedScore, locale)
|
|
1275
1307
|
};
|
|
1276
1308
|
}
|
|
1277
|
-
function getInterpretation(score) {
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
if (score >= 20) return "Difficult";
|
|
1309
|
+
function getInterpretation(score, locale = "de") {
|
|
1310
|
+
const lang = locale.toLowerCase().slice(0, 2);
|
|
1311
|
+
const bands = INTERPRETATION_BANDS[lang] ?? INTERPRETATION_BANDS.de;
|
|
1312
|
+
for (const band of bands) {
|
|
1313
|
+
if (score >= band.min) return band.label;
|
|
1314
|
+
}
|
|
1284
1315
|
return "Very difficult";
|
|
1285
1316
|
}
|
|
1286
1317
|
function isReadable(score, threshold) {
|
|
@@ -1321,7 +1352,8 @@ var lowReadability = {
|
|
|
1321
1352
|
return [];
|
|
1322
1353
|
}
|
|
1323
1354
|
const c = context.thresholds ? resolveThresholds(context.thresholds, item.contentType).content : CONTENT_DEFAULTS;
|
|
1324
|
-
const
|
|
1355
|
+
const locale = item.locale ?? context.defaultLocale ?? "de";
|
|
1356
|
+
const readability = calculateReadability(item.body, locale);
|
|
1325
1357
|
if (!isReadable(readability.score, c.minReadabilityScore)) {
|
|
1326
1358
|
const isExtreme = readability.score < 20;
|
|
1327
1359
|
const severity = isExtreme ? "error" : "warning";
|
|
@@ -13605,9 +13637,9 @@ var DEFAULT_CONFIG2 = {
|
|
|
13605
13637
|
function getLocaleConfig(locale) {
|
|
13606
13638
|
return LOCALE_CONFIGS[locale.toLowerCase()] ?? DEFAULT_CONFIG2;
|
|
13607
13639
|
}
|
|
13608
|
-
function isComplexWord(normalizedWord, originalWord, config, frequencyList) {
|
|
13640
|
+
function isComplexWord(normalizedWord, originalWord, config, frequencyList, locale = "en") {
|
|
13609
13641
|
if (normalizedWord.length <= config.minLength) return false;
|
|
13610
|
-
if (estimateSyllables(normalizedWord) < config.minSyllables) return false;
|
|
13642
|
+
if (estimateSyllables(normalizedWord, locale) < config.minSyllables) return false;
|
|
13611
13643
|
if (frequencyList?.has(normalizedWord)) return false;
|
|
13612
13644
|
if (config.skipCapitalized && /^[A-Z]/.test(originalWord)) return false;
|
|
13613
13645
|
return true;
|
|
@@ -13622,7 +13654,7 @@ function analyzeWordComplexity(body, locale = "en") {
|
|
|
13622
13654
|
let complexCount = 0;
|
|
13623
13655
|
for (const original of rawWords) {
|
|
13624
13656
|
const normalized = original.toLowerCase();
|
|
13625
|
-
if (isComplexWord(normalized, original, config, frequencyList)) {
|
|
13657
|
+
if (isComplexWord(normalized, original, config, frequencyList, locale)) {
|
|
13626
13658
|
complexCount++;
|
|
13627
13659
|
complexCounts.set(normalized, (complexCounts.get(normalized) ?? 0) + 1);
|
|
13628
13660
|
}
|
|
@@ -15372,10 +15404,10 @@ var jargonDensity = {
|
|
|
15372
15404
|
severity: "warning",
|
|
15373
15405
|
category: "content",
|
|
15374
15406
|
fixStrategy: "Replace complex or uncommon words with simpler alternatives",
|
|
15375
|
-
run: (item) => {
|
|
15407
|
+
run: (item, context) => {
|
|
15376
15408
|
const wordCount = countWords(item.body);
|
|
15377
15409
|
if (wordCount < QUALITY_MIN_WORDS) return [];
|
|
15378
|
-
const locale = item.locale ?? "en";
|
|
15410
|
+
const locale = item.locale ?? context.defaultLocale ?? "en";
|
|
15379
15411
|
const analysis = analyzeJargonDensity(item.body, locale);
|
|
15380
15412
|
if (analysis.density >= JARGON_ERROR_THRESHOLD) {
|
|
15381
15413
|
const topWords = analysis.topJargonWords.slice(0, 3).map((w) => `"${w.word}" (${w.count}x)`).join(", ");
|
|
@@ -15516,10 +15548,10 @@ var lowTransitionWords = {
|
|
|
15516
15548
|
severity: "warning",
|
|
15517
15549
|
category: "content",
|
|
15518
15550
|
fixStrategy: "Add transition words (however, therefore, for example, in addition) to connect ideas between sentences",
|
|
15519
|
-
run: (item) => {
|
|
15551
|
+
run: (item, context) => {
|
|
15520
15552
|
const wordCount = countWords(item.body);
|
|
15521
15553
|
if (wordCount < TRANSITION_MIN_WORDS) return [];
|
|
15522
|
-
const locale = item.locale ?? "en";
|
|
15554
|
+
const locale = item.locale ?? context.defaultLocale ?? "en";
|
|
15523
15555
|
const analysis = analyzeTransitionWords(item.body, locale);
|
|
15524
15556
|
if (analysis.totalSentences < 5) return [];
|
|
15525
15557
|
if (analysis.ratio < TRANSITION_ERROR_THRESHOLD) {
|
|
@@ -15550,10 +15582,10 @@ var consecutiveStarts = {
|
|
|
15550
15582
|
severity: "warning",
|
|
15551
15583
|
category: "content",
|
|
15552
15584
|
fixStrategy: "Vary sentence openings \u2014 use transition words, prepositional phrases, or reversed structures",
|
|
15553
|
-
run: (item) => {
|
|
15585
|
+
run: (item, context) => {
|
|
15554
15586
|
const wordCount = countWords(item.body);
|
|
15555
15587
|
if (wordCount < CONSECUTIVE_STARTS_MIN_WORDS) return [];
|
|
15556
|
-
const locale = item.locale ?? "en";
|
|
15588
|
+
const locale = item.locale ?? context.defaultLocale ?? "en";
|
|
15557
15589
|
const analysis = analyzeSentenceBeginnings(item.body, locale);
|
|
15558
15590
|
if (analysis.groups.length === 0) return [];
|
|
15559
15591
|
const worst = analysis.groups.reduce((a, b) => a.count > b.count ? a : b);
|
|
@@ -15801,7 +15833,8 @@ async function lint(options = {}) {
|
|
|
15801
15833
|
validSlugs: buildSlugRegistry(contentItems, config.staticRoutes, config.contentPaths),
|
|
15802
15834
|
validImages: buildImageRegistry(config.imageDirectories),
|
|
15803
15835
|
thresholds: config.thresholds,
|
|
15804
|
-
geoEnabledContentTypes: config.geo.enabledContentTypes ?? ["blog"]
|
|
15836
|
+
geoEnabledContentTypes: config.geo.enabledContentTypes ?? ["blog"],
|
|
15837
|
+
defaultLocale: config.i18n.defaultLocale
|
|
15805
15838
|
};
|
|
15806
15839
|
if (isPretty) {
|
|
15807
15840
|
printProgress(`Found ${context.validSlugs.size} valid URLs, ${context.validImages.size} images`);
|