@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/CHANGELOG.md +62 -0
- package/README.md +58 -7
- package/dist/cli.cjs +322 -25
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +322 -25
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +322 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.js +322 -25
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
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
|
|
1235
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
...
|
|
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
|
}
|