@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/cli.js
CHANGED
|
@@ -152,7 +152,13 @@ var DEFAULT_CONFIG = {
|
|
|
152
152
|
"Tabs",
|
|
153
153
|
"Tab"
|
|
154
154
|
],
|
|
155
|
-
enabledContentTypes: ["blog"]
|
|
155
|
+
enabledContentTypes: ["blog"],
|
|
156
|
+
organizationSameAs: [],
|
|
157
|
+
servicePagePatterns: []
|
|
158
|
+
},
|
|
159
|
+
technical: {
|
|
160
|
+
feedUrls: [],
|
|
161
|
+
llmsTxtUrl: ""
|
|
156
162
|
},
|
|
157
163
|
i18n: {
|
|
158
164
|
locales: ["de", "en"],
|
|
@@ -224,12 +230,18 @@ function mergeWithDefaults(user) {
|
|
|
224
230
|
acronymAllowlist: user.geo?.acronymAllowlist ?? DEFAULT_CONFIG.geo.acronymAllowlist,
|
|
225
231
|
vagueHeadings: user.geo?.vagueHeadings ?? DEFAULT_CONFIG.geo.vagueHeadings,
|
|
226
232
|
genericAuthorNames: user.geo?.genericAuthorNames ?? DEFAULT_CONFIG.geo.genericAuthorNames,
|
|
227
|
-
allowedHtmlTags: user.geo?.allowedHtmlTags ?? DEFAULT_CONFIG.geo.allowedHtmlTags
|
|
233
|
+
allowedHtmlTags: user.geo?.allowedHtmlTags ?? DEFAULT_CONFIG.geo.allowedHtmlTags,
|
|
234
|
+
organizationSameAs: user.geo?.organizationSameAs ?? DEFAULT_CONFIG.geo.organizationSameAs,
|
|
235
|
+
servicePagePatterns: user.geo?.servicePagePatterns ?? DEFAULT_CONFIG.geo.servicePagePatterns
|
|
228
236
|
},
|
|
229
237
|
i18n: {
|
|
230
238
|
locales: user.i18n?.locales ?? DEFAULT_CONFIG.i18n.locales,
|
|
231
239
|
defaultLocale: user.i18n?.defaultLocale ?? DEFAULT_CONFIG.i18n.defaultLocale
|
|
232
240
|
},
|
|
241
|
+
technical: {
|
|
242
|
+
feedUrls: user.technical?.feedUrls ?? DEFAULT_CONFIG.technical.feedUrls,
|
|
243
|
+
llmsTxtUrl: user.technical?.llmsTxtUrl ?? DEFAULT_CONFIG.technical.llmsTxtUrl
|
|
244
|
+
},
|
|
233
245
|
rules: { ...DEFAULT_CONFIG.rules, ...user.rules ?? {} },
|
|
234
246
|
thresholds: {
|
|
235
247
|
title: { ...DEFAULT_CONFIG.thresholds.title, ...user.thresholds?.title ?? {} },
|
|
@@ -2580,12 +2592,72 @@ var datasetSchemaReadiness = {
|
|
|
2580
2592
|
return results;
|
|
2581
2593
|
}
|
|
2582
2594
|
};
|
|
2583
|
-
var
|
|
2595
|
+
var MIN_SAMEAS_ENTRIES = 2;
|
|
2596
|
+
function createSchemaSameAsRule(organizationSameAs) {
|
|
2597
|
+
let hasFired = false;
|
|
2598
|
+
return {
|
|
2599
|
+
name: "seo-schema-sameas-incomplete",
|
|
2600
|
+
severity: "warning",
|
|
2601
|
+
category: "seo",
|
|
2602
|
+
fixStrategy: "Add social profiles (LinkedIn, GitHub, Twitter), Wikidata QID, and Crunchbase URL to Organization schema sameAs array",
|
|
2603
|
+
run: (_item) => {
|
|
2604
|
+
if (hasFired) return [];
|
|
2605
|
+
hasFired = true;
|
|
2606
|
+
if (!organizationSameAs || organizationSameAs.length === 0) return [];
|
|
2607
|
+
if (organizationSameAs.length < MIN_SAMEAS_ENTRIES) {
|
|
2608
|
+
return [
|
|
2609
|
+
{
|
|
2610
|
+
file: "_site",
|
|
2611
|
+
field: "schema",
|
|
2612
|
+
rule: "seo-schema-sameas-incomplete",
|
|
2613
|
+
severity: "warning",
|
|
2614
|
+
message: `Organization sameAs has ${organizationSameAs.length} entry \u2014 include at least ${MIN_SAMEAS_ENTRIES} for entity verification`,
|
|
2615
|
+
suggestion: "AI models use sameAs to verify entity identity. Include at least LinkedIn + one other profile (GitHub, Wikidata QID, Crunchbase)."
|
|
2616
|
+
}
|
|
2617
|
+
];
|
|
2618
|
+
}
|
|
2619
|
+
return [];
|
|
2620
|
+
}
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
function createServicePageSchemaRule(servicePagePatterns) {
|
|
2624
|
+
return {
|
|
2625
|
+
name: "seo-service-page-no-schema",
|
|
2626
|
+
severity: "warning",
|
|
2627
|
+
category: "seo",
|
|
2628
|
+
fixStrategy: "Add Service structured data (JSON-LD) to service pages with name, description, provider, and areaServed.",
|
|
2629
|
+
run: (item) => {
|
|
2630
|
+
if (!servicePagePatterns || servicePagePatterns.length === 0) return [];
|
|
2631
|
+
const matchesPattern = servicePagePatterns.some(
|
|
2632
|
+
(pattern) => item.permalink.includes(pattern)
|
|
2633
|
+
);
|
|
2634
|
+
if (!matchesPattern) return [];
|
|
2635
|
+
return [
|
|
2636
|
+
{
|
|
2637
|
+
file: getDisplayPath(item),
|
|
2638
|
+
field: "schema",
|
|
2639
|
+
rule: "seo-service-page-no-schema",
|
|
2640
|
+
severity: "warning",
|
|
2641
|
+
message: `Service page "${item.permalink}" should have Service structured data`,
|
|
2642
|
+
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.'
|
|
2643
|
+
}
|
|
2644
|
+
];
|
|
2645
|
+
}
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
var schemaStaticRules = [
|
|
2584
2649
|
blogMissingSchemaFields,
|
|
2585
2650
|
faqpageSchemaReadiness,
|
|
2586
2651
|
breadcrumblistSchemaReadiness,
|
|
2587
2652
|
datasetSchemaReadiness
|
|
2588
2653
|
];
|
|
2654
|
+
function createSchemaRules(geo) {
|
|
2655
|
+
return [
|
|
2656
|
+
...schemaStaticRules,
|
|
2657
|
+
createSchemaSameAsRule(geo.organizationSameAs),
|
|
2658
|
+
createServicePageSchemaRule(geo.servicePagePatterns)
|
|
2659
|
+
];
|
|
2660
|
+
}
|
|
2589
2661
|
|
|
2590
2662
|
// src/rules/keyword-coherence-rules.ts
|
|
2591
2663
|
var MIN_SIGNIFICANT_WORDS = 2;
|
|
@@ -14617,6 +14689,58 @@ var geoMissingTldr = {
|
|
|
14617
14689
|
return [];
|
|
14618
14690
|
}
|
|
14619
14691
|
};
|
|
14692
|
+
var ORG_AUTHOR_PATTERNS = [
|
|
14693
|
+
/\bteam\b/i,
|
|
14694
|
+
/\bredaktion\b/i,
|
|
14695
|
+
/\beditorial\b/i,
|
|
14696
|
+
/\beditors?\b/i,
|
|
14697
|
+
/\bherausgeber\b/i,
|
|
14698
|
+
/\bverlag\b/i,
|
|
14699
|
+
/\bredaktionsteam\b/i
|
|
14700
|
+
];
|
|
14701
|
+
function createGeoAuthorNotPersonRule(brandName) {
|
|
14702
|
+
return {
|
|
14703
|
+
name: "geo-author-not-person",
|
|
14704
|
+
severity: "warning",
|
|
14705
|
+
category: "geo",
|
|
14706
|
+
fixStrategy: "Replace organization name with individual author name. Use Person type in BlogPosting schema for stronger E-E-A-T signals.",
|
|
14707
|
+
run: (item, context) => {
|
|
14708
|
+
const geoTypes = context.geoEnabledContentTypes ?? ["blog"];
|
|
14709
|
+
if (!geoTypes.includes(item.contentType)) return [];
|
|
14710
|
+
if (!item.author || item.author.trim() === "") return [];
|
|
14711
|
+
if (!brandName || brandName.trim() === "") return [];
|
|
14712
|
+
const normalizedAuthor = item.author.trim().toLowerCase();
|
|
14713
|
+
if (normalizedAuthor === brandName.trim().toLowerCase()) {
|
|
14714
|
+
return [
|
|
14715
|
+
{
|
|
14716
|
+
file: getDisplayPath(item),
|
|
14717
|
+
field: "author",
|
|
14718
|
+
rule: "geo-author-not-person",
|
|
14719
|
+
severity: "warning",
|
|
14720
|
+
message: `Author "${item.author}" is the organization name \u2014 use a person's name instead`,
|
|
14721
|
+
suggestion: "AI models cite named experts over faceless organizations. Use the actual author's name for stronger E-E-A-T signals."
|
|
14722
|
+
}
|
|
14723
|
+
];
|
|
14724
|
+
}
|
|
14725
|
+
const matchesOrgPattern = ORG_AUTHOR_PATTERNS.some(
|
|
14726
|
+
(pattern) => pattern.test(item.author)
|
|
14727
|
+
);
|
|
14728
|
+
if (matchesOrgPattern) {
|
|
14729
|
+
return [
|
|
14730
|
+
{
|
|
14731
|
+
file: getDisplayPath(item),
|
|
14732
|
+
field: "author",
|
|
14733
|
+
rule: "geo-author-not-person",
|
|
14734
|
+
severity: "warning",
|
|
14735
|
+
message: `Author "${item.author}" appears to be an organization or team name`,
|
|
14736
|
+
suggestion: "BlogPosting with author.@type: Person gets cited more than Organization. Use an individual person's name."
|
|
14737
|
+
}
|
|
14738
|
+
];
|
|
14739
|
+
}
|
|
14740
|
+
return [];
|
|
14741
|
+
}
|
|
14742
|
+
};
|
|
14743
|
+
}
|
|
14620
14744
|
var geoEeatStaticRules = [
|
|
14621
14745
|
geoMissingSourceCitations,
|
|
14622
14746
|
geoMissingExpertQuotes,
|
|
@@ -14629,7 +14753,8 @@ function createGeoEeatRules(geo) {
|
|
|
14629
14753
|
return [
|
|
14630
14754
|
...geoEeatStaticRules,
|
|
14631
14755
|
createGeoMissingAuthorRule(geo.genericAuthorNames ?? []),
|
|
14632
|
-
createGeoHeadingTooVagueRule(geo.vagueHeadings ?? [])
|
|
14756
|
+
createGeoHeadingTooVagueRule(geo.vagueHeadings ?? []),
|
|
14757
|
+
createGeoAuthorNotPersonRule(geo.brandName)
|
|
14633
14758
|
];
|
|
14634
14759
|
}
|
|
14635
14760
|
|
|
@@ -15618,6 +15743,68 @@ var contentQualityRules = [
|
|
|
15618
15743
|
sentenceVariety
|
|
15619
15744
|
];
|
|
15620
15745
|
|
|
15746
|
+
// src/rules/technical-site-rules.ts
|
|
15747
|
+
function createNoFeedRule(feedUrls) {
|
|
15748
|
+
let hasFired = false;
|
|
15749
|
+
return {
|
|
15750
|
+
name: "technical-no-feed",
|
|
15751
|
+
severity: "warning",
|
|
15752
|
+
category: "technical",
|
|
15753
|
+
fixStrategy: "Add an RSS or JSON feed endpoint exposing blog posts with full content.",
|
|
15754
|
+
run: (_item, _context) => {
|
|
15755
|
+
if (hasFired) return [];
|
|
15756
|
+
hasFired = true;
|
|
15757
|
+
if (feedUrls === void 0) return [];
|
|
15758
|
+
if (feedUrls.length === 0) {
|
|
15759
|
+
return [
|
|
15760
|
+
{
|
|
15761
|
+
file: "_site",
|
|
15762
|
+
field: "feed",
|
|
15763
|
+
rule: "technical-no-feed",
|
|
15764
|
+
severity: "warning",
|
|
15765
|
+
message: "No RSS/Atom/JSON feed detected \u2014 AI systems lose a structured ingestion path",
|
|
15766
|
+
suggestion: "Feeds provide a structured ingestion path for AI systems beyond crawler discovery. Add an RSS or JSON feed endpoint."
|
|
15767
|
+
}
|
|
15768
|
+
];
|
|
15769
|
+
}
|
|
15770
|
+
return [];
|
|
15771
|
+
}
|
|
15772
|
+
};
|
|
15773
|
+
}
|
|
15774
|
+
function createNoLlmsTxtRule(llmsTxtUrl) {
|
|
15775
|
+
let hasFired = false;
|
|
15776
|
+
return {
|
|
15777
|
+
name: "technical-no-llms-txt",
|
|
15778
|
+
severity: "warning",
|
|
15779
|
+
category: "technical",
|
|
15780
|
+
fixStrategy: "Create a /llms.txt endpoint that maps your most important content for LLM consumption in Markdown format.",
|
|
15781
|
+
run: (_item, _context) => {
|
|
15782
|
+
if (hasFired) return [];
|
|
15783
|
+
hasFired = true;
|
|
15784
|
+
if (llmsTxtUrl === void 0) return [];
|
|
15785
|
+
if (llmsTxtUrl.trim() === "") {
|
|
15786
|
+
return [
|
|
15787
|
+
{
|
|
15788
|
+
file: "_site",
|
|
15789
|
+
field: "llms-txt",
|
|
15790
|
+
rule: "technical-no-llms-txt",
|
|
15791
|
+
severity: "warning",
|
|
15792
|
+
message: "No /llms.txt endpoint detected \u2014 missing the emerging standard for LLM content declaration",
|
|
15793
|
+
suggestion: "llms.txt is the robots.txt equivalent for AI \u2014 trivial to add, future-proofs your site for LLM crawlers."
|
|
15794
|
+
}
|
|
15795
|
+
];
|
|
15796
|
+
}
|
|
15797
|
+
return [];
|
|
15798
|
+
}
|
|
15799
|
+
};
|
|
15800
|
+
}
|
|
15801
|
+
function createTechnicalSiteRules(technical) {
|
|
15802
|
+
return [
|
|
15803
|
+
createNoFeedRule(technical.feedUrls),
|
|
15804
|
+
createNoLlmsTxtRule(technical.llmsTxtUrl)
|
|
15805
|
+
];
|
|
15806
|
+
}
|
|
15807
|
+
|
|
15621
15808
|
// src/rules/index.ts
|
|
15622
15809
|
function buildRules(config, linkExtractor) {
|
|
15623
15810
|
const rules = [
|
|
@@ -15637,7 +15824,7 @@ function buildRules(config, linkExtractor) {
|
|
|
15637
15824
|
...createI18nRules(config.i18n),
|
|
15638
15825
|
...dateRules,
|
|
15639
15826
|
...config.categories.length > 0 ? createCategoryRules(config.categories) : [],
|
|
15640
|
-
...
|
|
15827
|
+
...createSchemaRules(config.geo),
|
|
15641
15828
|
...createGeoRules(config.geo),
|
|
15642
15829
|
...createGeoEeatRules(config.geo),
|
|
15643
15830
|
...geoStructureRules,
|
|
@@ -15645,7 +15832,8 @@ function buildRules(config, linkExtractor) {
|
|
|
15645
15832
|
...createGeoRagRules(config.geo),
|
|
15646
15833
|
...keywordCoherenceRules,
|
|
15647
15834
|
...createCanonicalRules(config.siteUrl),
|
|
15648
|
-
...contentQualityRules
|
|
15835
|
+
...contentQualityRules,
|
|
15836
|
+
...createTechnicalSiteRules(config.technical)
|
|
15649
15837
|
];
|
|
15650
15838
|
return rules.map((rule) => applyRuleOverride(rule, config.rules));
|
|
15651
15839
|
}
|
|
@@ -15829,8 +16017,7 @@ async function lint(options = {}) {
|
|
|
15829
16017
|
} else {
|
|
15830
16018
|
formatResults(results, lintableItems.length, excludedItems.length);
|
|
15831
16019
|
}
|
|
15832
|
-
|
|
15833
|
-
return errorCount > 0 ? 1 : 0;
|
|
16020
|
+
return 0;
|
|
15834
16021
|
}
|
|
15835
16022
|
|
|
15836
16023
|
// src/cli.ts
|