@ijonis/geo-lint 0.1.5 → 0.2.1

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 ?? {} },
@@ -886,6 +898,68 @@ var duplicateRules = [
886
898
  duplicateDescription
887
899
  ];
888
900
 
901
+ // src/utils/plaintext-structure.ts
902
+ var MAX_HEADING_LENGTH = 80;
903
+ var MIN_TABLE_ROWS = 2;
904
+ function detectPlaintextHeadings(text) {
905
+ const lines = text.split("\n");
906
+ const headings = [];
907
+ for (let i = 0; i < lines.length; i++) {
908
+ const line = lines[i].trim();
909
+ if (!line || line.length > MAX_HEADING_LENGTH) continue;
910
+ const nextLine = lines[i + 1]?.trim() ?? "";
911
+ const isFollowedByBlank = i + 1 >= lines.length || nextLine === "";
912
+ if (!isFollowedByBlank) continue;
913
+ if (/[.,;:]$/.test(line)) continue;
914
+ const isTitleCase = /^[A-ZÄÖÜ]/.test(line) && line.split(/\s+/).length <= 12;
915
+ const isAllCaps = line === line.toUpperCase() && /[A-ZÄÖÜ]/.test(line) && line.length > 2;
916
+ const isQuestion = line.endsWith("?");
917
+ if (isTitleCase || isAllCaps || isQuestion) {
918
+ const level = isAllCaps || line.split(/\s+/).length <= 4 ? 2 : 3;
919
+ headings.push({ level, text: line, line: i + 1 });
920
+ }
921
+ }
922
+ return headings;
923
+ }
924
+ function detectPlaintextTable(text) {
925
+ const lines = text.split("\n").filter((l) => l.trim().length > 0);
926
+ const tabLines = lines.filter((l) => l.includes(" "));
927
+ if (tabLines.length >= MIN_TABLE_ROWS) {
928
+ const colCounts = tabLines.map((l) => l.split(" ").length);
929
+ const consistent = colCounts.every(
930
+ (c) => c === colCounts[0] && c >= 2
931
+ );
932
+ if (consistent) return true;
933
+ }
934
+ const spaceSeparated = lines.filter((l) => /\S {3,}\S/.test(l));
935
+ if (spaceSeparated.length >= MIN_TABLE_ROWS + 1) {
936
+ return true;
937
+ }
938
+ return false;
939
+ }
940
+ function detectPlaintextList(text) {
941
+ const listPattern = /^[\s]*[•·–—]\s+|^[\s]*\w\)\s+|^[\s]*\d+\)\s+/m;
942
+ const lines = text.split("\n").filter((l) => listPattern.test(l));
943
+ return lines.length >= 2;
944
+ }
945
+ function detectPlaintextFaq(text) {
946
+ const lines = text.split("\n");
947
+ let questionCount = 0;
948
+ for (let i = 0; i < lines.length; i++) {
949
+ const line = lines[i].trim();
950
+ if (!line.endsWith("?")) continue;
951
+ if (line.length > MAX_HEADING_LENGTH) continue;
952
+ const nextContent = lines.slice(i + 1).find((l) => l.trim().length > 0);
953
+ if (nextContent && nextContent.trim().length > line.length) {
954
+ questionCount++;
955
+ }
956
+ }
957
+ return {
958
+ hasFaq: questionCount >= 2,
959
+ questionCount
960
+ };
961
+ }
962
+
889
963
  // src/utils/heading-extractor.ts
890
964
  function isInCodeBlock(lines, lineIndex) {
891
965
  let inCodeBlock = false;
@@ -897,7 +971,7 @@ function isInCodeBlock(lines, lineIndex) {
897
971
  }
898
972
  return inCodeBlock;
899
973
  }
900
- function extractHeadings(mdxBody) {
974
+ function extractHeadings(mdxBody, contentSource) {
901
975
  const headings = [];
902
976
  const lines = mdxBody.split("\n");
903
977
  const headingRegex = /^(#{1,6})\s+(.+)$/;
@@ -914,6 +988,9 @@ function extractHeadings(mdxBody) {
914
988
  });
915
989
  }
916
990
  }
991
+ if (headings.length === 0 && contentSource === "url") {
992
+ return detectPlaintextHeadings(mdxBody);
993
+ }
917
994
  return headings;
918
995
  }
919
996
  function countH1s(headings) {
@@ -945,6 +1022,9 @@ var missingH1 = {
945
1022
  category: "seo",
946
1023
  fixStrategy: "Add an H1 heading (# Heading) at the start of the content",
947
1024
  run: (item) => {
1025
+ if (item.contentSource === "url") {
1026
+ return [];
1027
+ }
948
1028
  if (item.contentType === "blog") {
949
1029
  return [];
950
1030
  }
@@ -1231,8 +1311,16 @@ function countWords(text) {
1231
1311
  }
1232
1312
  function countSentences(text) {
1233
1313
  const stripped = stripMarkdown(text);
1234
- const sentences = stripped.match(/[.!?]+(?:\s|$)/g);
1235
- return sentences ? sentences.length : 0;
1314
+ const sentenceEndings = stripped.match(/[.!?]+(?:\s|$|(?=[A-ZÄÖÜ]))/g);
1315
+ if (sentenceEndings && sentenceEndings.length > 0) {
1316
+ return sentenceEndings.length;
1317
+ }
1318
+ const lines = stripped.split(/\n+/).filter((l) => l.trim().length > 20);
1319
+ if (lines.length > 1) {
1320
+ return lines.length;
1321
+ }
1322
+ const hasWords = /\w{2,}/.test(stripped);
1323
+ return hasWords ? 1 : 0;
1236
1324
  }
1237
1325
 
1238
1326
  // src/utils/readability.ts
@@ -1514,6 +1602,7 @@ var robotsRules = [
1514
1602
  // src/rules/slug-rules.ts
1515
1603
  var SLUG_DEFAULTS = { maxLength: 75 };
1516
1604
  var SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1605
+ var URL_PATH_PATTERN = /^[a-z0-9]+(?:[-/][a-z0-9]+)*$/;
1517
1606
  var slugInvalidCharacters = {
1518
1607
  name: "slug-invalid-characters",
1519
1608
  severity: "error",
@@ -1521,8 +1610,10 @@ var slugInvalidCharacters = {
1521
1610
  fixStrategy: 'Use lowercase alphanumeric characters with hyphens only (e.g., "my-blog-post")',
1522
1611
  run: (item) => {
1523
1612
  if (!item.slug) return [];
1613
+ const isUrl = item.contentSource === "url";
1614
+ const pattern = isUrl ? URL_PATH_PATTERN : SLUG_PATTERN;
1524
1615
  const hasUppercase = /[A-Z]/.test(item.slug);
1525
- const matchesPattern = SLUG_PATTERN.test(item.slug);
1616
+ const matchesPattern = pattern.test(item.slug);
1526
1617
  if (hasUppercase || !matchesPattern) {
1527
1618
  return [{
1528
1619
  file: getDisplayPath(item),
@@ -1530,7 +1621,7 @@ var slugInvalidCharacters = {
1530
1621
  rule: "slug-invalid-characters",
1531
1622
  severity: "error",
1532
1623
  message: `Slug "${item.slug}" contains invalid characters`,
1533
- suggestion: 'Slugs must be lowercase alphanumeric with hyphens only (e.g., "my-blog-post")'
1624
+ suggestion: isUrl ? "URL paths must be lowercase alphanumeric with hyphens and slashes only" : 'Slugs must be lowercase alphanumeric with hyphens only (e.g., "my-blog-post")'
1534
1625
  }];
1535
1626
  }
1536
1627
  return [];
@@ -1795,8 +1886,8 @@ var WEAK_LEAD_STARTS = [
1795
1886
  "schauen wir uns"
1796
1887
  ];
1797
1888
  var TABLE_SEPARATOR_PATTERN = /\|\s*:?-{2,}/;
1798
- function countQuestionHeadings(body) {
1799
- const headings = extractHeadings(body);
1889
+ function countQuestionHeadings(body, contentSource) {
1890
+ const headings = extractHeadings(body, contentSource);
1800
1891
  let count = 0;
1801
1892
  for (const heading of headings) {
1802
1893
  const text = heading.text.trim();
@@ -1858,12 +1949,20 @@ function countStatistics(body) {
1858
1949
  }
1859
1950
  return matches.size;
1860
1951
  }
1861
- function hasFAQSection(body) {
1952
+ function hasFAQSection(body, contentSource) {
1862
1953
  const faqPattern = /#{2,3}\s*(FAQ|Häufige Fragen|Frequently Asked|Fragen und Antworten)/i;
1863
- return faqPattern.test(body);
1954
+ if (faqPattern.test(body)) return true;
1955
+ if (contentSource === "url") {
1956
+ return detectPlaintextFaq(body).hasFaq;
1957
+ }
1958
+ return false;
1864
1959
  }
1865
- function hasMarkdownTable(body) {
1866
- return TABLE_SEPARATOR_PATTERN.test(body);
1960
+ function hasMarkdownTable(body, contentSource) {
1961
+ if (TABLE_SEPARATOR_PATTERN.test(body)) return true;
1962
+ if (contentSource === "url") {
1963
+ return detectPlaintextTable(body);
1964
+ }
1965
+ return false;
1867
1966
  }
1868
1967
  function countEntityMentions(body, entity) {
1869
1968
  const escapedEntity = entity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -2025,7 +2124,7 @@ function getParagraphs(body) {
2025
2124
  }
2026
2125
  return paragraphs;
2027
2126
  }
2028
- function hasMarkdownList(body) {
2127
+ function hasMarkdownList(body, contentSource) {
2029
2128
  const lines = body.split("\n");
2030
2129
  let inCodeBlock = false;
2031
2130
  for (const line of lines) {
@@ -2038,6 +2137,9 @@ function hasMarkdownList(body) {
2038
2137
  if (/^[-*]\s+/.test(trimmed)) return true;
2039
2138
  if (/^\d+\.\s+/.test(trimmed)) return true;
2040
2139
  }
2140
+ if (contentSource === "url") {
2141
+ return detectPlaintextList(body);
2142
+ }
2041
2143
  return false;
2042
2144
  }
2043
2145
  function countInternalLinks(body) {
@@ -2622,12 +2724,72 @@ var datasetSchemaReadiness = {
2622
2724
  return results;
2623
2725
  }
2624
2726
  };
2625
- var schemaRules = [
2727
+ var MIN_SAMEAS_ENTRIES = 2;
2728
+ function createSchemaSameAsRule(organizationSameAs) {
2729
+ let hasFired = false;
2730
+ return {
2731
+ name: "seo-schema-sameas-incomplete",
2732
+ severity: "warning",
2733
+ category: "seo",
2734
+ fixStrategy: "Add social profiles (LinkedIn, GitHub, Twitter), Wikidata QID, and Crunchbase URL to Organization schema sameAs array",
2735
+ run: (_item) => {
2736
+ if (hasFired) return [];
2737
+ hasFired = true;
2738
+ if (!organizationSameAs || organizationSameAs.length === 0) return [];
2739
+ if (organizationSameAs.length < MIN_SAMEAS_ENTRIES) {
2740
+ return [
2741
+ {
2742
+ file: "_site",
2743
+ field: "schema",
2744
+ rule: "seo-schema-sameas-incomplete",
2745
+ severity: "warning",
2746
+ message: `Organization sameAs has ${organizationSameAs.length} entry \u2014 include at least ${MIN_SAMEAS_ENTRIES} for entity verification`,
2747
+ suggestion: "AI models use sameAs to verify entity identity. Include at least LinkedIn + one other profile (GitHub, Wikidata QID, Crunchbase)."
2748
+ }
2749
+ ];
2750
+ }
2751
+ return [];
2752
+ }
2753
+ };
2754
+ }
2755
+ function createServicePageSchemaRule(servicePagePatterns) {
2756
+ return {
2757
+ name: "seo-service-page-no-schema",
2758
+ severity: "warning",
2759
+ category: "seo",
2760
+ fixStrategy: "Add Service structured data (JSON-LD) to service pages with name, description, provider, and areaServed.",
2761
+ run: (item) => {
2762
+ if (!servicePagePatterns || servicePagePatterns.length === 0) return [];
2763
+ const matchesPattern = servicePagePatterns.some(
2764
+ (pattern) => item.permalink.includes(pattern)
2765
+ );
2766
+ if (!matchesPattern) return [];
2767
+ return [
2768
+ {
2769
+ file: getDisplayPath(item),
2770
+ field: "schema",
2771
+ rule: "seo-service-page-no-schema",
2772
+ severity: "warning",
2773
+ message: `Service page "${item.permalink}" should have Service structured data`,
2774
+ 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.'
2775
+ }
2776
+ ];
2777
+ }
2778
+ };
2779
+ }
2780
+ var schemaStaticRules = [
2626
2781
  blogMissingSchemaFields,
2627
2782
  faqpageSchemaReadiness,
2628
2783
  breadcrumblistSchemaReadiness,
2629
2784
  datasetSchemaReadiness
2630
2785
  ];
2786
+ function createSchemaRules(geo) {
2787
+ return [
2788
+ ...schemaStaticRules,
2789
+ createSchemaSameAsRule(geo.organizationSameAs),
2790
+ createServicePageSchemaRule(geo.servicePagePatterns)
2791
+ ];
2792
+ }
2631
2793
 
2632
2794
  // src/rules/keyword-coherence-rules.ts
2633
2795
  var MIN_SIGNIFICANT_WORDS = 2;
@@ -13709,8 +13871,27 @@ function jaccardSimilarity(a, b) {
13709
13871
  const union = a.size + b.size - intersection;
13710
13872
  return union > 0 ? intersection / union : 0;
13711
13873
  }
13874
+ var REFERENCE_PATTERNS = [
13875
+ /archived from the original on/gi,
13876
+ /retrieved (?:on )?\d/gi,
13877
+ /accessed (?:on )?\d/gi,
13878
+ /cite (?:web|book|journal|news)/gi,
13879
+ /\^\s*\[?\d+\]?/g,
13880
+ /isbn \d/gi,
13881
+ /doi:\s*\d/gi,
13882
+ /pmid:\s*\d/gi
13883
+ ];
13884
+ function stripReferenceBoilerplate(text) {
13885
+ let result = text;
13886
+ for (const pattern of REFERENCE_PATTERNS) {
13887
+ result = result.replace(pattern, "");
13888
+ }
13889
+ result = result.replace(/\n(?:references|sources|bibliography|einzelnachweise|weblinks)\n[\s\S]*$/i, "");
13890
+ return result;
13891
+ }
13712
13892
  function analyzeRepetition(body) {
13713
- const plain = stripMarkdown(body).toLowerCase();
13893
+ const cleaned = stripReferenceBoilerplate(body);
13894
+ const plain = stripMarkdown(cleaned).toLowerCase();
13714
13895
  const words = plain.replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter((w) => w.length > 0);
13715
13896
  const fiveGrams = extractNgrams(words, 5);
13716
13897
  const phraseCounts = /* @__PURE__ */ new Map();
@@ -13719,7 +13900,7 @@ function analyzeRepetition(body) {
13719
13900
  }
13720
13901
  const repeatedPhrases = [...phraseCounts.entries()].filter(([, count]) => count >= 3).sort((a, b) => b[1] - a[1]);
13721
13902
  const topRepeatedPhrases = repeatedPhrases.slice(0, 5).map(([phrase, count]) => ({ phrase, count }));
13722
- const paragraphs = body.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0 && !p.startsWith("#") && !p.startsWith("|"));
13903
+ const paragraphs = cleaned.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0 && !p.startsWith("#") && !p.startsWith("|"));
13723
13904
  let totalSimilarity = 0;
13724
13905
  let pairCount = 0;
13725
13906
  for (let i = 0; i < paragraphs.length; i++) {
@@ -14025,10 +14206,10 @@ var geoNoQuestionHeadings = {
14025
14206
  if (!geoTypes.includes(item.contentType)) return [];
14026
14207
  const wordCount = countWords(item.body);
14027
14208
  if (wordCount < GEO_MIN_WORDS) return [];
14028
- const headings = extractHeadings(item.body);
14209
+ const headings = extractHeadings(item.body, item.contentSource);
14029
14210
  const subHeadings = headings.filter((h) => h.level === 2 || h.level === 3);
14030
14211
  if (subHeadings.length === 0) return [];
14031
- const questionCount = countQuestionHeadings(item.body);
14212
+ const questionCount = countQuestionHeadings(item.body, item.contentSource);
14032
14213
  const ratio = questionCount / subHeadings.length;
14033
14214
  if (ratio < QUESTION_HEADING_THRESHOLD) {
14034
14215
  return [{
@@ -14118,7 +14299,7 @@ var geoMissingFaqSection = {
14118
14299
  if (!geoTypes.includes(item.contentType)) return [];
14119
14300
  const wordCount = countWords(item.body);
14120
14301
  if (wordCount < FAQ_MIN_WORDS) return [];
14121
- if (!hasFAQSection(item.body)) {
14302
+ if (!hasFAQSection(item.body, item.contentSource)) {
14122
14303
  return [{
14123
14304
  file: getDisplayPath(item),
14124
14305
  field: "body",
@@ -14163,7 +14344,7 @@ var geoMissingTable = {
14163
14344
  if (!geoTypes.includes(item.contentType)) return [];
14164
14345
  const wordCount = countWords(item.body);
14165
14346
  if (wordCount < TABLE_MIN_WORDS) return [];
14166
- if (!hasMarkdownTable(item.body)) {
14347
+ if (!hasMarkdownTable(item.body, item.contentSource)) {
14167
14348
  return [{
14168
14349
  file: getDisplayPath(item),
14169
14350
  field: "body",
@@ -14659,6 +14840,58 @@ var geoMissingTldr = {
14659
14840
  return [];
14660
14841
  }
14661
14842
  };
14843
+ var ORG_AUTHOR_PATTERNS = [
14844
+ /\bteam\b/i,
14845
+ /\bredaktion\b/i,
14846
+ /\beditorial\b/i,
14847
+ /\beditors?\b/i,
14848
+ /\bherausgeber\b/i,
14849
+ /\bverlag\b/i,
14850
+ /\bredaktionsteam\b/i
14851
+ ];
14852
+ function createGeoAuthorNotPersonRule(brandName) {
14853
+ return {
14854
+ name: "geo-author-not-person",
14855
+ severity: "warning",
14856
+ category: "geo",
14857
+ fixStrategy: "Replace organization name with individual author name. Use Person type in BlogPosting schema for stronger E-E-A-T signals.",
14858
+ run: (item, context) => {
14859
+ const geoTypes = context.geoEnabledContentTypes ?? ["blog"];
14860
+ if (!geoTypes.includes(item.contentType)) return [];
14861
+ if (!item.author || item.author.trim() === "") return [];
14862
+ if (!brandName || brandName.trim() === "") return [];
14863
+ const normalizedAuthor = item.author.trim().toLowerCase();
14864
+ if (normalizedAuthor === brandName.trim().toLowerCase()) {
14865
+ return [
14866
+ {
14867
+ file: getDisplayPath(item),
14868
+ field: "author",
14869
+ rule: "geo-author-not-person",
14870
+ severity: "warning",
14871
+ message: `Author "${item.author}" is the organization name \u2014 use a person's name instead`,
14872
+ suggestion: "AI models cite named experts over faceless organizations. Use the actual author's name for stronger E-E-A-T signals."
14873
+ }
14874
+ ];
14875
+ }
14876
+ const matchesOrgPattern = ORG_AUTHOR_PATTERNS.some(
14877
+ (pattern) => pattern.test(item.author)
14878
+ );
14879
+ if (matchesOrgPattern) {
14880
+ return [
14881
+ {
14882
+ file: getDisplayPath(item),
14883
+ field: "author",
14884
+ rule: "geo-author-not-person",
14885
+ severity: "warning",
14886
+ message: `Author "${item.author}" appears to be an organization or team name`,
14887
+ suggestion: "BlogPosting with author.@type: Person gets cited more than Organization. Use an individual person's name."
14888
+ }
14889
+ ];
14890
+ }
14891
+ return [];
14892
+ }
14893
+ };
14894
+ }
14662
14895
  var geoEeatStaticRules = [
14663
14896
  geoMissingSourceCitations,
14664
14897
  geoMissingExpertQuotes,
@@ -14671,7 +14904,8 @@ function createGeoEeatRules(geo) {
14671
14904
  return [
14672
14905
  ...geoEeatStaticRules,
14673
14906
  createGeoMissingAuthorRule(geo.genericAuthorNames ?? []),
14674
- createGeoHeadingTooVagueRule(geo.vagueHeadings ?? [])
14907
+ createGeoHeadingTooVagueRule(geo.vagueHeadings ?? []),
14908
+ createGeoAuthorNotPersonRule(geo.brandName)
14675
14909
  ];
14676
14910
  }
14677
14911
 
@@ -14758,7 +14992,7 @@ var geoMissingLists = {
14758
14992
  if (!geoTypes.includes(item.contentType)) return [];
14759
14993
  const wordCount = countWords(item.body);
14760
14994
  if (wordCount < STRUCTURE_MIN_WORDS) return [];
14761
- if (!hasMarkdownList(item.body)) {
14995
+ if (!hasMarkdownList(item.body, item.contentSource)) {
14762
14996
  return [{
14763
14997
  file: getDisplayPath(item),
14764
14998
  field: "body",
@@ -15660,6 +15894,68 @@ var contentQualityRules = [
15660
15894
  sentenceVariety
15661
15895
  ];
15662
15896
 
15897
+ // src/rules/technical-site-rules.ts
15898
+ function createNoFeedRule(feedUrls) {
15899
+ let hasFired = false;
15900
+ return {
15901
+ name: "technical-no-feed",
15902
+ severity: "warning",
15903
+ category: "technical",
15904
+ fixStrategy: "Add an RSS or JSON feed endpoint exposing blog posts with full content.",
15905
+ run: (_item, _context) => {
15906
+ if (hasFired) return [];
15907
+ hasFired = true;
15908
+ if (feedUrls === void 0) return [];
15909
+ if (feedUrls.length === 0) {
15910
+ return [
15911
+ {
15912
+ file: "_site",
15913
+ field: "feed",
15914
+ rule: "technical-no-feed",
15915
+ severity: "warning",
15916
+ message: "No RSS/Atom/JSON feed detected \u2014 AI systems lose a structured ingestion path",
15917
+ suggestion: "Feeds provide a structured ingestion path for AI systems beyond crawler discovery. Add an RSS or JSON feed endpoint."
15918
+ }
15919
+ ];
15920
+ }
15921
+ return [];
15922
+ }
15923
+ };
15924
+ }
15925
+ function createNoLlmsTxtRule(llmsTxtUrl) {
15926
+ let hasFired = false;
15927
+ return {
15928
+ name: "technical-no-llms-txt",
15929
+ severity: "warning",
15930
+ category: "technical",
15931
+ fixStrategy: "Create a /llms.txt endpoint that maps your most important content for LLM consumption in Markdown format.",
15932
+ run: (_item, _context) => {
15933
+ if (hasFired) return [];
15934
+ hasFired = true;
15935
+ if (llmsTxtUrl === void 0) return [];
15936
+ if (llmsTxtUrl.trim() === "") {
15937
+ return [
15938
+ {
15939
+ file: "_site",
15940
+ field: "llms-txt",
15941
+ rule: "technical-no-llms-txt",
15942
+ severity: "warning",
15943
+ message: "No /llms.txt endpoint detected \u2014 missing the emerging standard for LLM content declaration",
15944
+ suggestion: "llms.txt is the robots.txt equivalent for AI \u2014 trivial to add, future-proofs your site for LLM crawlers."
15945
+ }
15946
+ ];
15947
+ }
15948
+ return [];
15949
+ }
15950
+ };
15951
+ }
15952
+ function createTechnicalSiteRules(technical) {
15953
+ return [
15954
+ createNoFeedRule(technical.feedUrls),
15955
+ createNoLlmsTxtRule(technical.llmsTxtUrl)
15956
+ ];
15957
+ }
15958
+
15663
15959
  // src/rules/index.ts
15664
15960
  function buildRules(config, linkExtractor) {
15665
15961
  const rules = [
@@ -15679,7 +15975,7 @@ function buildRules(config, linkExtractor) {
15679
15975
  ...createI18nRules(config.i18n),
15680
15976
  ...dateRules,
15681
15977
  ...config.categories.length > 0 ? createCategoryRules(config.categories) : [],
15682
- ...schemaRules,
15978
+ ...createSchemaRules(config.geo),
15683
15979
  ...createGeoRules(config.geo),
15684
15980
  ...createGeoEeatRules(config.geo),
15685
15981
  ...geoStructureRules,
@@ -15687,7 +15983,8 @@ function buildRules(config, linkExtractor) {
15687
15983
  ...createGeoRagRules(config.geo),
15688
15984
  ...keywordCoherenceRules,
15689
15985
  ...createCanonicalRules(config.siteUrl),
15690
- ...contentQualityRules
15986
+ ...contentQualityRules,
15987
+ ...createTechnicalSiteRules(config.technical)
15691
15988
  ];
15692
15989
  return rules.map((rule) => applyRuleOverride(rule, config.rules));
15693
15990
  }