@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/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 schemaRules = [
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
- ...schemaRules,
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
- const errorCount = results.filter((r) => r.severity === "error").length;
15833
- return errorCount > 0 ? 1 : 0;
16020
+ return 0;
15834
16021
  }
15835
16022
 
15836
16023
  // src/cli.ts