@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.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 schemaRules = [
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
- ...schemaRules,
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
- const errorCount = results.filter((r) => r.severity === "error").length;
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());