@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/dist/index.d.cts CHANGED
@@ -161,6 +161,10 @@ interface GeoConfig {
161
161
  genericAuthorNames?: string[];
162
162
  /** MDX component tags allowed in markdown (not flagged by inline-html rule) */
163
163
  allowedHtmlTags?: string[];
164
+ /** Organization sameAs URLs for entity verification (LinkedIn, GitHub, Wikidata QID, etc.) */
165
+ organizationSameAs?: string[];
166
+ /** URL patterns identifying service pages (e.g. ['/services/', '/leistungen/']) */
167
+ servicePagePatterns?: string[];
164
168
  }
165
169
  /** Internationalization configuration */
166
170
  interface I18nConfig {
@@ -169,6 +173,13 @@ interface I18nConfig {
169
173
  /** Default locale — maps to hreflang x-default. Must be in locales array */
170
174
  defaultLocale: string;
171
175
  }
176
+ /** Technical site-level configuration */
177
+ interface TechnicalConfig {
178
+ /** Declared feed URLs (e.g. ['/feed.xml', '/rss.xml']) */
179
+ feedUrls?: string[];
180
+ /** Path to llms.txt file (e.g. '/llms.txt') */
181
+ llmsTxtUrl?: string;
182
+ }
172
183
  /** User-facing configuration (partial, with defaults applied) */
173
184
  interface GeoLintUserConfig {
174
185
  /** Canonical site URL, e.g. 'https://example.com' (required) */
@@ -189,6 +200,8 @@ interface GeoLintUserConfig {
189
200
  geo?: Partial<GeoConfig>;
190
201
  /** Internationalization configuration */
191
202
  i18n?: Partial<I18nConfig>;
203
+ /** Technical site-level configuration */
204
+ technical?: Partial<TechnicalConfig>;
192
205
  /** Rule severity overrides: 'off' disables a rule */
193
206
  rules?: Record<string, 'error' | 'warning' | 'off'>;
194
207
  /** Threshold overrides */
@@ -227,6 +240,8 @@ interface GeoLintConfig {
227
240
  excludeCategories: string[];
228
241
  geo: GeoConfig;
229
242
  i18n: I18nConfig;
243
+ /** Technical site-level configuration */
244
+ technical: TechnicalConfig;
230
245
  rules: Record<string, 'error' | 'warning' | 'off'>;
231
246
  thresholds: ThresholdConfig;
232
247
  }
@@ -299,4 +314,4 @@ declare function lint(options?: LintOptions): Promise<number>;
299
314
  */
300
315
  declare function lintQuiet(options?: LintOptions): Promise<LintResult[]>;
301
316
 
302
- export { type ContentAdapter, type ContentItem, type ContentPathConfig, type ContentType, type ExtractedImage, type ExtractedLink, type GeoConfig, type GeoLintConfig, type GeoLintUserConfig, type GlobalRule, type Heading, type I18nConfig, type LintOptions, type LintResult, type Rule, type RuleContext, type Severity, type ThresholdConfig, createAdapter, defineConfig, lint, lintQuiet, loadContentItems };
317
+ export { type ContentAdapter, type ContentItem, type ContentPathConfig, type ContentType, type ExtractedImage, type ExtractedLink, type GeoConfig, type GeoLintConfig, type GeoLintUserConfig, type GlobalRule, type Heading, type I18nConfig, type LintOptions, type LintResult, type Rule, type RuleContext, type Severity, type TechnicalConfig, type ThresholdConfig, createAdapter, defineConfig, lint, lintQuiet, loadContentItems };
package/dist/index.d.ts CHANGED
@@ -161,6 +161,10 @@ interface GeoConfig {
161
161
  genericAuthorNames?: string[];
162
162
  /** MDX component tags allowed in markdown (not flagged by inline-html rule) */
163
163
  allowedHtmlTags?: string[];
164
+ /** Organization sameAs URLs for entity verification (LinkedIn, GitHub, Wikidata QID, etc.) */
165
+ organizationSameAs?: string[];
166
+ /** URL patterns identifying service pages (e.g. ['/services/', '/leistungen/']) */
167
+ servicePagePatterns?: string[];
164
168
  }
165
169
  /** Internationalization configuration */
166
170
  interface I18nConfig {
@@ -169,6 +173,13 @@ interface I18nConfig {
169
173
  /** Default locale — maps to hreflang x-default. Must be in locales array */
170
174
  defaultLocale: string;
171
175
  }
176
+ /** Technical site-level configuration */
177
+ interface TechnicalConfig {
178
+ /** Declared feed URLs (e.g. ['/feed.xml', '/rss.xml']) */
179
+ feedUrls?: string[];
180
+ /** Path to llms.txt file (e.g. '/llms.txt') */
181
+ llmsTxtUrl?: string;
182
+ }
172
183
  /** User-facing configuration (partial, with defaults applied) */
173
184
  interface GeoLintUserConfig {
174
185
  /** Canonical site URL, e.g. 'https://example.com' (required) */
@@ -189,6 +200,8 @@ interface GeoLintUserConfig {
189
200
  geo?: Partial<GeoConfig>;
190
201
  /** Internationalization configuration */
191
202
  i18n?: Partial<I18nConfig>;
203
+ /** Technical site-level configuration */
204
+ technical?: Partial<TechnicalConfig>;
192
205
  /** Rule severity overrides: 'off' disables a rule */
193
206
  rules?: Record<string, 'error' | 'warning' | 'off'>;
194
207
  /** Threshold overrides */
@@ -227,6 +240,8 @@ interface GeoLintConfig {
227
240
  excludeCategories: string[];
228
241
  geo: GeoConfig;
229
242
  i18n: I18nConfig;
243
+ /** Technical site-level configuration */
244
+ technical: TechnicalConfig;
230
245
  rules: Record<string, 'error' | 'warning' | 'off'>;
231
246
  thresholds: ThresholdConfig;
232
247
  }
@@ -299,4 +314,4 @@ declare function lint(options?: LintOptions): Promise<number>;
299
314
  */
300
315
  declare function lintQuiet(options?: LintOptions): Promise<LintResult[]>;
301
316
 
302
- export { type ContentAdapter, type ContentItem, type ContentPathConfig, type ContentType, type ExtractedImage, type ExtractedLink, type GeoConfig, type GeoLintConfig, type GeoLintUserConfig, type GlobalRule, type Heading, type I18nConfig, type LintOptions, type LintResult, type Rule, type RuleContext, type Severity, type ThresholdConfig, createAdapter, defineConfig, lint, lintQuiet, loadContentItems };
317
+ export { type ContentAdapter, type ContentItem, type ContentPathConfig, type ContentType, type ExtractedImage, type ExtractedLink, type GeoConfig, type GeoLintConfig, type GeoLintUserConfig, type GlobalRule, type Heading, type I18nConfig, type LintOptions, type LintResult, type Rule, type RuleContext, type Severity, type TechnicalConfig, type ThresholdConfig, createAdapter, defineConfig, lint, lintQuiet, loadContentItems };
package/dist/index.js CHANGED
@@ -147,7 +147,13 @@ var DEFAULT_CONFIG = {
147
147
  "Tabs",
148
148
  "Tab"
149
149
  ],
150
- enabledContentTypes: ["blog"]
150
+ enabledContentTypes: ["blog"],
151
+ organizationSameAs: [],
152
+ servicePagePatterns: []
153
+ },
154
+ technical: {
155
+ feedUrls: [],
156
+ llmsTxtUrl: ""
151
157
  },
152
158
  i18n: {
153
159
  locales: ["de", "en"],
@@ -222,12 +228,18 @@ function mergeWithDefaults(user) {
222
228
  acronymAllowlist: user.geo?.acronymAllowlist ?? DEFAULT_CONFIG.geo.acronymAllowlist,
223
229
  vagueHeadings: user.geo?.vagueHeadings ?? DEFAULT_CONFIG.geo.vagueHeadings,
224
230
  genericAuthorNames: user.geo?.genericAuthorNames ?? DEFAULT_CONFIG.geo.genericAuthorNames,
225
- allowedHtmlTags: user.geo?.allowedHtmlTags ?? DEFAULT_CONFIG.geo.allowedHtmlTags
231
+ allowedHtmlTags: user.geo?.allowedHtmlTags ?? DEFAULT_CONFIG.geo.allowedHtmlTags,
232
+ organizationSameAs: user.geo?.organizationSameAs ?? DEFAULT_CONFIG.geo.organizationSameAs,
233
+ servicePagePatterns: user.geo?.servicePagePatterns ?? DEFAULT_CONFIG.geo.servicePagePatterns
226
234
  },
227
235
  i18n: {
228
236
  locales: user.i18n?.locales ?? DEFAULT_CONFIG.i18n.locales,
229
237
  defaultLocale: user.i18n?.defaultLocale ?? DEFAULT_CONFIG.i18n.defaultLocale
230
238
  },
239
+ technical: {
240
+ feedUrls: user.technical?.feedUrls ?? DEFAULT_CONFIG.technical.feedUrls,
241
+ llmsTxtUrl: user.technical?.llmsTxtUrl ?? DEFAULT_CONFIG.technical.llmsTxtUrl
242
+ },
231
243
  rules: { ...DEFAULT_CONFIG.rules, ...user.rules ?? {} },
232
244
  thresholds: {
233
245
  title: { ...DEFAULT_CONFIG.thresholds.title, ...user.thresholds?.title ?? {} },
@@ -2578,12 +2590,72 @@ var datasetSchemaReadiness = {
2578
2590
  return results;
2579
2591
  }
2580
2592
  };
2581
- var schemaRules = [
2593
+ var MIN_SAMEAS_ENTRIES = 2;
2594
+ function createSchemaSameAsRule(organizationSameAs) {
2595
+ let hasFired = false;
2596
+ return {
2597
+ name: "seo-schema-sameas-incomplete",
2598
+ severity: "warning",
2599
+ category: "seo",
2600
+ fixStrategy: "Add social profiles (LinkedIn, GitHub, Twitter), Wikidata QID, and Crunchbase URL to Organization schema sameAs array",
2601
+ run: (_item) => {
2602
+ if (hasFired) return [];
2603
+ hasFired = true;
2604
+ if (!organizationSameAs || organizationSameAs.length === 0) return [];
2605
+ if (organizationSameAs.length < MIN_SAMEAS_ENTRIES) {
2606
+ return [
2607
+ {
2608
+ file: "_site",
2609
+ field: "schema",
2610
+ rule: "seo-schema-sameas-incomplete",
2611
+ severity: "warning",
2612
+ message: `Organization sameAs has ${organizationSameAs.length} entry \u2014 include at least ${MIN_SAMEAS_ENTRIES} for entity verification`,
2613
+ suggestion: "AI models use sameAs to verify entity identity. Include at least LinkedIn + one other profile (GitHub, Wikidata QID, Crunchbase)."
2614
+ }
2615
+ ];
2616
+ }
2617
+ return [];
2618
+ }
2619
+ };
2620
+ }
2621
+ function createServicePageSchemaRule(servicePagePatterns) {
2622
+ return {
2623
+ name: "seo-service-page-no-schema",
2624
+ severity: "warning",
2625
+ category: "seo",
2626
+ fixStrategy: "Add Service structured data (JSON-LD) to service pages with name, description, provider, and areaServed.",
2627
+ run: (item) => {
2628
+ if (!servicePagePatterns || servicePagePatterns.length === 0) return [];
2629
+ const matchesPattern = servicePagePatterns.some(
2630
+ (pattern) => item.permalink.includes(pattern)
2631
+ );
2632
+ if (!matchesPattern) return [];
2633
+ return [
2634
+ {
2635
+ file: getDisplayPath(item),
2636
+ field: "schema",
2637
+ rule: "seo-service-page-no-schema",
2638
+ severity: "warning",
2639
+ message: `Service page "${item.permalink}" should have Service structured data`,
2640
+ 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.'
2641
+ }
2642
+ ];
2643
+ }
2644
+ };
2645
+ }
2646
+ var schemaStaticRules = [
2582
2647
  blogMissingSchemaFields,
2583
2648
  faqpageSchemaReadiness,
2584
2649
  breadcrumblistSchemaReadiness,
2585
2650
  datasetSchemaReadiness
2586
2651
  ];
2652
+ function createSchemaRules(geo) {
2653
+ return [
2654
+ ...schemaStaticRules,
2655
+ createSchemaSameAsRule(geo.organizationSameAs),
2656
+ createServicePageSchemaRule(geo.servicePagePatterns)
2657
+ ];
2658
+ }
2587
2659
 
2588
2660
  // src/rules/keyword-coherence-rules.ts
2589
2661
  var MIN_SIGNIFICANT_WORDS = 2;
@@ -14615,6 +14687,58 @@ var geoMissingTldr = {
14615
14687
  return [];
14616
14688
  }
14617
14689
  };
14690
+ var ORG_AUTHOR_PATTERNS = [
14691
+ /\bteam\b/i,
14692
+ /\bredaktion\b/i,
14693
+ /\beditorial\b/i,
14694
+ /\beditors?\b/i,
14695
+ /\bherausgeber\b/i,
14696
+ /\bverlag\b/i,
14697
+ /\bredaktionsteam\b/i
14698
+ ];
14699
+ function createGeoAuthorNotPersonRule(brandName) {
14700
+ return {
14701
+ name: "geo-author-not-person",
14702
+ severity: "warning",
14703
+ category: "geo",
14704
+ fixStrategy: "Replace organization name with individual author name. Use Person type in BlogPosting schema for stronger E-E-A-T signals.",
14705
+ run: (item, context) => {
14706
+ const geoTypes = context.geoEnabledContentTypes ?? ["blog"];
14707
+ if (!geoTypes.includes(item.contentType)) return [];
14708
+ if (!item.author || item.author.trim() === "") return [];
14709
+ if (!brandName || brandName.trim() === "") return [];
14710
+ const normalizedAuthor = item.author.trim().toLowerCase();
14711
+ if (normalizedAuthor === brandName.trim().toLowerCase()) {
14712
+ return [
14713
+ {
14714
+ file: getDisplayPath(item),
14715
+ field: "author",
14716
+ rule: "geo-author-not-person",
14717
+ severity: "warning",
14718
+ message: `Author "${item.author}" is the organization name \u2014 use a person's name instead`,
14719
+ suggestion: "AI models cite named experts over faceless organizations. Use the actual author's name for stronger E-E-A-T signals."
14720
+ }
14721
+ ];
14722
+ }
14723
+ const matchesOrgPattern = ORG_AUTHOR_PATTERNS.some(
14724
+ (pattern) => pattern.test(item.author)
14725
+ );
14726
+ if (matchesOrgPattern) {
14727
+ return [
14728
+ {
14729
+ file: getDisplayPath(item),
14730
+ field: "author",
14731
+ rule: "geo-author-not-person",
14732
+ severity: "warning",
14733
+ message: `Author "${item.author}" appears to be an organization or team name`,
14734
+ suggestion: "BlogPosting with author.@type: Person gets cited more than Organization. Use an individual person's name."
14735
+ }
14736
+ ];
14737
+ }
14738
+ return [];
14739
+ }
14740
+ };
14741
+ }
14618
14742
  var geoEeatStaticRules = [
14619
14743
  geoMissingSourceCitations,
14620
14744
  geoMissingExpertQuotes,
@@ -14627,7 +14751,8 @@ function createGeoEeatRules(geo) {
14627
14751
  return [
14628
14752
  ...geoEeatStaticRules,
14629
14753
  createGeoMissingAuthorRule(geo.genericAuthorNames ?? []),
14630
- createGeoHeadingTooVagueRule(geo.vagueHeadings ?? [])
14754
+ createGeoHeadingTooVagueRule(geo.vagueHeadings ?? []),
14755
+ createGeoAuthorNotPersonRule(geo.brandName)
14631
14756
  ];
14632
14757
  }
14633
14758
 
@@ -15616,6 +15741,68 @@ var contentQualityRules = [
15616
15741
  sentenceVariety
15617
15742
  ];
15618
15743
 
15744
+ // src/rules/technical-site-rules.ts
15745
+ function createNoFeedRule(feedUrls) {
15746
+ let hasFired = false;
15747
+ return {
15748
+ name: "technical-no-feed",
15749
+ severity: "warning",
15750
+ category: "technical",
15751
+ fixStrategy: "Add an RSS or JSON feed endpoint exposing blog posts with full content.",
15752
+ run: (_item, _context) => {
15753
+ if (hasFired) return [];
15754
+ hasFired = true;
15755
+ if (feedUrls === void 0) return [];
15756
+ if (feedUrls.length === 0) {
15757
+ return [
15758
+ {
15759
+ file: "_site",
15760
+ field: "feed",
15761
+ rule: "technical-no-feed",
15762
+ severity: "warning",
15763
+ message: "No RSS/Atom/JSON feed detected \u2014 AI systems lose a structured ingestion path",
15764
+ suggestion: "Feeds provide a structured ingestion path for AI systems beyond crawler discovery. Add an RSS or JSON feed endpoint."
15765
+ }
15766
+ ];
15767
+ }
15768
+ return [];
15769
+ }
15770
+ };
15771
+ }
15772
+ function createNoLlmsTxtRule(llmsTxtUrl) {
15773
+ let hasFired = false;
15774
+ return {
15775
+ name: "technical-no-llms-txt",
15776
+ severity: "warning",
15777
+ category: "technical",
15778
+ fixStrategy: "Create a /llms.txt endpoint that maps your most important content for LLM consumption in Markdown format.",
15779
+ run: (_item, _context) => {
15780
+ if (hasFired) return [];
15781
+ hasFired = true;
15782
+ if (llmsTxtUrl === void 0) return [];
15783
+ if (llmsTxtUrl.trim() === "") {
15784
+ return [
15785
+ {
15786
+ file: "_site",
15787
+ field: "llms-txt",
15788
+ rule: "technical-no-llms-txt",
15789
+ severity: "warning",
15790
+ message: "No /llms.txt endpoint detected \u2014 missing the emerging standard for LLM content declaration",
15791
+ suggestion: "llms.txt is the robots.txt equivalent for AI \u2014 trivial to add, future-proofs your site for LLM crawlers."
15792
+ }
15793
+ ];
15794
+ }
15795
+ return [];
15796
+ }
15797
+ };
15798
+ }
15799
+ function createTechnicalSiteRules(technical) {
15800
+ return [
15801
+ createNoFeedRule(technical.feedUrls),
15802
+ createNoLlmsTxtRule(technical.llmsTxtUrl)
15803
+ ];
15804
+ }
15805
+
15619
15806
  // src/rules/index.ts
15620
15807
  function buildRules(config, linkExtractor) {
15621
15808
  const rules = [
@@ -15635,7 +15822,7 @@ function buildRules(config, linkExtractor) {
15635
15822
  ...createI18nRules(config.i18n),
15636
15823
  ...dateRules,
15637
15824
  ...config.categories.length > 0 ? createCategoryRules(config.categories) : [],
15638
- ...schemaRules,
15825
+ ...createSchemaRules(config.geo),
15639
15826
  ...createGeoRules(config.geo),
15640
15827
  ...createGeoEeatRules(config.geo),
15641
15828
  ...geoStructureRules,
@@ -15643,7 +15830,8 @@ function buildRules(config, linkExtractor) {
15643
15830
  ...createGeoRagRules(config.geo),
15644
15831
  ...keywordCoherenceRules,
15645
15832
  ...createCanonicalRules(config.siteUrl),
15646
- ...contentQualityRules
15833
+ ...contentQualityRules,
15834
+ ...createTechnicalSiteRules(config.technical)
15647
15835
  ];
15648
15836
  return rules.map((rule) => applyRuleOverride(rule, config.rules));
15649
15837
  }
@@ -15832,8 +16020,7 @@ async function lint(options = {}) {
15832
16020
  } else {
15833
16021
  formatResults(results, lintableItems.length, excludedItems.length);
15834
16022
  }
15835
- const errorCount = results.filter((r) => r.severity === "error").length;
15836
- return errorCount > 0 ? 1 : 0;
16023
+ return 0;
15837
16024
  }
15838
16025
  async function lintQuiet(options = {}) {
15839
16026
  const projectRoot = resolve2(options.projectRoot ?? process.cwd());