@ijonis/geo-lint 0.1.4 → 0.2.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/CHANGELOG.md +47 -0
- package/README.md +58 -7
- package/dist/cli.cjs +195 -8
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +195 -8
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +195 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +195 -8
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
package/dist/index.cjs
CHANGED
|
@@ -191,7 +191,13 @@ var DEFAULT_CONFIG = {
|
|
|
191
191
|
"Tabs",
|
|
192
192
|
"Tab"
|
|
193
193
|
],
|
|
194
|
-
enabledContentTypes: ["blog"]
|
|
194
|
+
enabledContentTypes: ["blog"],
|
|
195
|
+
organizationSameAs: [],
|
|
196
|
+
servicePagePatterns: []
|
|
197
|
+
},
|
|
198
|
+
technical: {
|
|
199
|
+
feedUrls: [],
|
|
200
|
+
llmsTxtUrl: ""
|
|
195
201
|
},
|
|
196
202
|
i18n: {
|
|
197
203
|
locales: ["de", "en"],
|
|
@@ -266,12 +272,18 @@ function mergeWithDefaults(user) {
|
|
|
266
272
|
acronymAllowlist: user.geo?.acronymAllowlist ?? DEFAULT_CONFIG.geo.acronymAllowlist,
|
|
267
273
|
vagueHeadings: user.geo?.vagueHeadings ?? DEFAULT_CONFIG.geo.vagueHeadings,
|
|
268
274
|
genericAuthorNames: user.geo?.genericAuthorNames ?? DEFAULT_CONFIG.geo.genericAuthorNames,
|
|
269
|
-
allowedHtmlTags: user.geo?.allowedHtmlTags ?? DEFAULT_CONFIG.geo.allowedHtmlTags
|
|
275
|
+
allowedHtmlTags: user.geo?.allowedHtmlTags ?? DEFAULT_CONFIG.geo.allowedHtmlTags,
|
|
276
|
+
organizationSameAs: user.geo?.organizationSameAs ?? DEFAULT_CONFIG.geo.organizationSameAs,
|
|
277
|
+
servicePagePatterns: user.geo?.servicePagePatterns ?? DEFAULT_CONFIG.geo.servicePagePatterns
|
|
270
278
|
},
|
|
271
279
|
i18n: {
|
|
272
280
|
locales: user.i18n?.locales ?? DEFAULT_CONFIG.i18n.locales,
|
|
273
281
|
defaultLocale: user.i18n?.defaultLocale ?? DEFAULT_CONFIG.i18n.defaultLocale
|
|
274
282
|
},
|
|
283
|
+
technical: {
|
|
284
|
+
feedUrls: user.technical?.feedUrls ?? DEFAULT_CONFIG.technical.feedUrls,
|
|
285
|
+
llmsTxtUrl: user.technical?.llmsTxtUrl ?? DEFAULT_CONFIG.technical.llmsTxtUrl
|
|
286
|
+
},
|
|
275
287
|
rules: { ...DEFAULT_CONFIG.rules, ...user.rules ?? {} },
|
|
276
288
|
thresholds: {
|
|
277
289
|
title: { ...DEFAULT_CONFIG.thresholds.title, ...user.thresholds?.title ?? {} },
|
|
@@ -2622,12 +2634,72 @@ var datasetSchemaReadiness = {
|
|
|
2622
2634
|
return results;
|
|
2623
2635
|
}
|
|
2624
2636
|
};
|
|
2625
|
-
var
|
|
2637
|
+
var MIN_SAMEAS_ENTRIES = 2;
|
|
2638
|
+
function createSchemaSameAsRule(organizationSameAs) {
|
|
2639
|
+
let hasFired = false;
|
|
2640
|
+
return {
|
|
2641
|
+
name: "seo-schema-sameas-incomplete",
|
|
2642
|
+
severity: "warning",
|
|
2643
|
+
category: "seo",
|
|
2644
|
+
fixStrategy: "Add social profiles (LinkedIn, GitHub, Twitter), Wikidata QID, and Crunchbase URL to Organization schema sameAs array",
|
|
2645
|
+
run: (_item) => {
|
|
2646
|
+
if (hasFired) return [];
|
|
2647
|
+
hasFired = true;
|
|
2648
|
+
if (!organizationSameAs || organizationSameAs.length === 0) return [];
|
|
2649
|
+
if (organizationSameAs.length < MIN_SAMEAS_ENTRIES) {
|
|
2650
|
+
return [
|
|
2651
|
+
{
|
|
2652
|
+
file: "_site",
|
|
2653
|
+
field: "schema",
|
|
2654
|
+
rule: "seo-schema-sameas-incomplete",
|
|
2655
|
+
severity: "warning",
|
|
2656
|
+
message: `Organization sameAs has ${organizationSameAs.length} entry \u2014 include at least ${MIN_SAMEAS_ENTRIES} for entity verification`,
|
|
2657
|
+
suggestion: "AI models use sameAs to verify entity identity. Include at least LinkedIn + one other profile (GitHub, Wikidata QID, Crunchbase)."
|
|
2658
|
+
}
|
|
2659
|
+
];
|
|
2660
|
+
}
|
|
2661
|
+
return [];
|
|
2662
|
+
}
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
function createServicePageSchemaRule(servicePagePatterns) {
|
|
2666
|
+
return {
|
|
2667
|
+
name: "seo-service-page-no-schema",
|
|
2668
|
+
severity: "warning",
|
|
2669
|
+
category: "seo",
|
|
2670
|
+
fixStrategy: "Add Service structured data (JSON-LD) to service pages with name, description, provider, and areaServed.",
|
|
2671
|
+
run: (item) => {
|
|
2672
|
+
if (!servicePagePatterns || servicePagePatterns.length === 0) return [];
|
|
2673
|
+
const matchesPattern = servicePagePatterns.some(
|
|
2674
|
+
(pattern) => item.permalink.includes(pattern)
|
|
2675
|
+
);
|
|
2676
|
+
if (!matchesPattern) return [];
|
|
2677
|
+
return [
|
|
2678
|
+
{
|
|
2679
|
+
file: getDisplayPath(item),
|
|
2680
|
+
field: "schema",
|
|
2681
|
+
rule: "seo-service-page-no-schema",
|
|
2682
|
+
severity: "warning",
|
|
2683
|
+
message: `Service page "${item.permalink}" should have Service structured data`,
|
|
2684
|
+
suggestion: 'Service pages need schema markup to appear in AI answers for "[service] provider in [city]" queries. Add Service JSON-LD with name, description, provider, and areaServed.'
|
|
2685
|
+
}
|
|
2686
|
+
];
|
|
2687
|
+
}
|
|
2688
|
+
};
|
|
2689
|
+
}
|
|
2690
|
+
var schemaStaticRules = [
|
|
2626
2691
|
blogMissingSchemaFields,
|
|
2627
2692
|
faqpageSchemaReadiness,
|
|
2628
2693
|
breadcrumblistSchemaReadiness,
|
|
2629
2694
|
datasetSchemaReadiness
|
|
2630
2695
|
];
|
|
2696
|
+
function createSchemaRules(geo) {
|
|
2697
|
+
return [
|
|
2698
|
+
...schemaStaticRules,
|
|
2699
|
+
createSchemaSameAsRule(geo.organizationSameAs),
|
|
2700
|
+
createServicePageSchemaRule(geo.servicePagePatterns)
|
|
2701
|
+
];
|
|
2702
|
+
}
|
|
2631
2703
|
|
|
2632
2704
|
// src/rules/keyword-coherence-rules.ts
|
|
2633
2705
|
var MIN_SIGNIFICANT_WORDS = 2;
|
|
@@ -14659,6 +14731,58 @@ var geoMissingTldr = {
|
|
|
14659
14731
|
return [];
|
|
14660
14732
|
}
|
|
14661
14733
|
};
|
|
14734
|
+
var ORG_AUTHOR_PATTERNS = [
|
|
14735
|
+
/\bteam\b/i,
|
|
14736
|
+
/\bredaktion\b/i,
|
|
14737
|
+
/\beditorial\b/i,
|
|
14738
|
+
/\beditors?\b/i,
|
|
14739
|
+
/\bherausgeber\b/i,
|
|
14740
|
+
/\bverlag\b/i,
|
|
14741
|
+
/\bredaktionsteam\b/i
|
|
14742
|
+
];
|
|
14743
|
+
function createGeoAuthorNotPersonRule(brandName) {
|
|
14744
|
+
return {
|
|
14745
|
+
name: "geo-author-not-person",
|
|
14746
|
+
severity: "warning",
|
|
14747
|
+
category: "geo",
|
|
14748
|
+
fixStrategy: "Replace organization name with individual author name. Use Person type in BlogPosting schema for stronger E-E-A-T signals.",
|
|
14749
|
+
run: (item, context) => {
|
|
14750
|
+
const geoTypes = context.geoEnabledContentTypes ?? ["blog"];
|
|
14751
|
+
if (!geoTypes.includes(item.contentType)) return [];
|
|
14752
|
+
if (!item.author || item.author.trim() === "") return [];
|
|
14753
|
+
if (!brandName || brandName.trim() === "") return [];
|
|
14754
|
+
const normalizedAuthor = item.author.trim().toLowerCase();
|
|
14755
|
+
if (normalizedAuthor === brandName.trim().toLowerCase()) {
|
|
14756
|
+
return [
|
|
14757
|
+
{
|
|
14758
|
+
file: getDisplayPath(item),
|
|
14759
|
+
field: "author",
|
|
14760
|
+
rule: "geo-author-not-person",
|
|
14761
|
+
severity: "warning",
|
|
14762
|
+
message: `Author "${item.author}" is the organization name \u2014 use a person's name instead`,
|
|
14763
|
+
suggestion: "AI models cite named experts over faceless organizations. Use the actual author's name for stronger E-E-A-T signals."
|
|
14764
|
+
}
|
|
14765
|
+
];
|
|
14766
|
+
}
|
|
14767
|
+
const matchesOrgPattern = ORG_AUTHOR_PATTERNS.some(
|
|
14768
|
+
(pattern) => pattern.test(item.author)
|
|
14769
|
+
);
|
|
14770
|
+
if (matchesOrgPattern) {
|
|
14771
|
+
return [
|
|
14772
|
+
{
|
|
14773
|
+
file: getDisplayPath(item),
|
|
14774
|
+
field: "author",
|
|
14775
|
+
rule: "geo-author-not-person",
|
|
14776
|
+
severity: "warning",
|
|
14777
|
+
message: `Author "${item.author}" appears to be an organization or team name`,
|
|
14778
|
+
suggestion: "BlogPosting with author.@type: Person gets cited more than Organization. Use an individual person's name."
|
|
14779
|
+
}
|
|
14780
|
+
];
|
|
14781
|
+
}
|
|
14782
|
+
return [];
|
|
14783
|
+
}
|
|
14784
|
+
};
|
|
14785
|
+
}
|
|
14662
14786
|
var geoEeatStaticRules = [
|
|
14663
14787
|
geoMissingSourceCitations,
|
|
14664
14788
|
geoMissingExpertQuotes,
|
|
@@ -14671,7 +14795,8 @@ function createGeoEeatRules(geo) {
|
|
|
14671
14795
|
return [
|
|
14672
14796
|
...geoEeatStaticRules,
|
|
14673
14797
|
createGeoMissingAuthorRule(geo.genericAuthorNames ?? []),
|
|
14674
|
-
createGeoHeadingTooVagueRule(geo.vagueHeadings ?? [])
|
|
14798
|
+
createGeoHeadingTooVagueRule(geo.vagueHeadings ?? []),
|
|
14799
|
+
createGeoAuthorNotPersonRule(geo.brandName)
|
|
14675
14800
|
];
|
|
14676
14801
|
}
|
|
14677
14802
|
|
|
@@ -15660,6 +15785,68 @@ var contentQualityRules = [
|
|
|
15660
15785
|
sentenceVariety
|
|
15661
15786
|
];
|
|
15662
15787
|
|
|
15788
|
+
// src/rules/technical-site-rules.ts
|
|
15789
|
+
function createNoFeedRule(feedUrls) {
|
|
15790
|
+
let hasFired = false;
|
|
15791
|
+
return {
|
|
15792
|
+
name: "technical-no-feed",
|
|
15793
|
+
severity: "warning",
|
|
15794
|
+
category: "technical",
|
|
15795
|
+
fixStrategy: "Add an RSS or JSON feed endpoint exposing blog posts with full content.",
|
|
15796
|
+
run: (_item, _context) => {
|
|
15797
|
+
if (hasFired) return [];
|
|
15798
|
+
hasFired = true;
|
|
15799
|
+
if (feedUrls === void 0) return [];
|
|
15800
|
+
if (feedUrls.length === 0) {
|
|
15801
|
+
return [
|
|
15802
|
+
{
|
|
15803
|
+
file: "_site",
|
|
15804
|
+
field: "feed",
|
|
15805
|
+
rule: "technical-no-feed",
|
|
15806
|
+
severity: "warning",
|
|
15807
|
+
message: "No RSS/Atom/JSON feed detected \u2014 AI systems lose a structured ingestion path",
|
|
15808
|
+
suggestion: "Feeds provide a structured ingestion path for AI systems beyond crawler discovery. Add an RSS or JSON feed endpoint."
|
|
15809
|
+
}
|
|
15810
|
+
];
|
|
15811
|
+
}
|
|
15812
|
+
return [];
|
|
15813
|
+
}
|
|
15814
|
+
};
|
|
15815
|
+
}
|
|
15816
|
+
function createNoLlmsTxtRule(llmsTxtUrl) {
|
|
15817
|
+
let hasFired = false;
|
|
15818
|
+
return {
|
|
15819
|
+
name: "technical-no-llms-txt",
|
|
15820
|
+
severity: "warning",
|
|
15821
|
+
category: "technical",
|
|
15822
|
+
fixStrategy: "Create a /llms.txt endpoint that maps your most important content for LLM consumption in Markdown format.",
|
|
15823
|
+
run: (_item, _context) => {
|
|
15824
|
+
if (hasFired) return [];
|
|
15825
|
+
hasFired = true;
|
|
15826
|
+
if (llmsTxtUrl === void 0) return [];
|
|
15827
|
+
if (llmsTxtUrl.trim() === "") {
|
|
15828
|
+
return [
|
|
15829
|
+
{
|
|
15830
|
+
file: "_site",
|
|
15831
|
+
field: "llms-txt",
|
|
15832
|
+
rule: "technical-no-llms-txt",
|
|
15833
|
+
severity: "warning",
|
|
15834
|
+
message: "No /llms.txt endpoint detected \u2014 missing the emerging standard for LLM content declaration",
|
|
15835
|
+
suggestion: "llms.txt is the robots.txt equivalent for AI \u2014 trivial to add, future-proofs your site for LLM crawlers."
|
|
15836
|
+
}
|
|
15837
|
+
];
|
|
15838
|
+
}
|
|
15839
|
+
return [];
|
|
15840
|
+
}
|
|
15841
|
+
};
|
|
15842
|
+
}
|
|
15843
|
+
function createTechnicalSiteRules(technical) {
|
|
15844
|
+
return [
|
|
15845
|
+
createNoFeedRule(technical.feedUrls),
|
|
15846
|
+
createNoLlmsTxtRule(technical.llmsTxtUrl)
|
|
15847
|
+
];
|
|
15848
|
+
}
|
|
15849
|
+
|
|
15663
15850
|
// src/rules/index.ts
|
|
15664
15851
|
function buildRules(config, linkExtractor) {
|
|
15665
15852
|
const rules = [
|
|
@@ -15679,7 +15866,7 @@ function buildRules(config, linkExtractor) {
|
|
|
15679
15866
|
...createI18nRules(config.i18n),
|
|
15680
15867
|
...dateRules,
|
|
15681
15868
|
...config.categories.length > 0 ? createCategoryRules(config.categories) : [],
|
|
15682
|
-
...
|
|
15869
|
+
...createSchemaRules(config.geo),
|
|
15683
15870
|
...createGeoRules(config.geo),
|
|
15684
15871
|
...createGeoEeatRules(config.geo),
|
|
15685
15872
|
...geoStructureRules,
|
|
@@ -15687,7 +15874,8 @@ function buildRules(config, linkExtractor) {
|
|
|
15687
15874
|
...createGeoRagRules(config.geo),
|
|
15688
15875
|
...keywordCoherenceRules,
|
|
15689
15876
|
...createCanonicalRules(config.siteUrl),
|
|
15690
|
-
...contentQualityRules
|
|
15877
|
+
...contentQualityRules,
|
|
15878
|
+
...createTechnicalSiteRules(config.technical)
|
|
15691
15879
|
];
|
|
15692
15880
|
return rules.map((rule) => applyRuleOverride(rule, config.rules));
|
|
15693
15881
|
}
|
|
@@ -15876,8 +16064,7 @@ async function lint(options = {}) {
|
|
|
15876
16064
|
} else {
|
|
15877
16065
|
formatResults(results, lintableItems.length, excludedItems.length);
|
|
15878
16066
|
}
|
|
15879
|
-
|
|
15880
|
-
return errorCount > 0 ? 1 : 0;
|
|
16067
|
+
return 0;
|
|
15881
16068
|
}
|
|
15882
16069
|
async function lintQuiet(options = {}) {
|
|
15883
16070
|
const projectRoot = (0, import_node_path4.resolve)(options.projectRoot ?? process.cwd());
|