turva-llms-txt-validator 0.3.0 → 0.3.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/package.json +1 -1
- package/src/index.mjs +67 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turva-llms-txt-validator",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Validate a site's llms.txt structure: eight checks as pass/warn/fail plus two llms.txt v2 discovery checks as information, JSON output. Same checks as the hosted validator at turva.dev/llms-txt-validator.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
package/src/index.mjs
CHANGED
|
@@ -103,6 +103,68 @@ export async function fetchLlmsTxt(host, opts = {}) {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// Every markdown link in the file, scanned once from left to right instead of collected with
|
|
107
|
+
// matchAll(/\[([^\][]*)\]\(([^)\s]{1,2048})\)/g). That bound meant a target longer than 2048
|
|
108
|
+
// characters was not counted as a link at all, and dropping the bound from the pattern would
|
|
109
|
+
// make it quadratic on a file that repeats "[a](" (Erik 2026-08-29). The scan carries no bound
|
|
110
|
+
// and no backtracking: every character is read once and the furthest failed target scan is
|
|
111
|
+
// remembered. matchAll resumes after a whole match, and so does this.
|
|
112
|
+
function collectLinks(text) {
|
|
113
|
+
const out = [];
|
|
114
|
+
let failEnd = -1;
|
|
115
|
+
for (let i = 0; i < text.length; i++) {
|
|
116
|
+
if (text[i] !== "[") continue;
|
|
117
|
+
let j = i + 1;
|
|
118
|
+
while (j < text.length && text[j] !== "]" && text[j] !== "[") j++;
|
|
119
|
+
if (j >= text.length) break;
|
|
120
|
+
if (text[j] === "[") { i = j - 1; continue; }
|
|
121
|
+
if (text[j + 1] !== "(") { i = j; continue; }
|
|
122
|
+
const k = j + 2;
|
|
123
|
+
if (k <= failEnd) { i = j + 1; continue; }
|
|
124
|
+
let e = k;
|
|
125
|
+
while (e < text.length && text[e] !== ")" && !/\s/.test(text[e])) e++;
|
|
126
|
+
if (e > k && text[e] === ")") { out.push({ name: text.slice(i + 1, j), target: text.slice(k, e) }); i = e; continue; }
|
|
127
|
+
if (text[e] !== ")") failEnd = e;
|
|
128
|
+
i = j + 1;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A markdown list item that carries a link, scanned once from left to right instead of
|
|
134
|
+
// matched with /^ {0,3}[-*+] .*\[[^\][]*\]\([^)\s]+\)/. That pattern is quadratic on a line
|
|
135
|
+
// such as "- " followed by "[a](" repeated, because every candidate rescans the target to the
|
|
136
|
+
// end of the line, and the line comes from the audited site (CodeQL js/polynomial-redos,
|
|
137
|
+
// 2026-08-29). Bounding the quantifier would trade the speed bug for a silent accuracy bug,
|
|
138
|
+
// so the scan is by index: every character is read once and the furthest failed target scan
|
|
139
|
+
// is remembered.
|
|
140
|
+
function listItemHasLink(l) {
|
|
141
|
+
const m = /^ {0,3}[-*+] /.exec(l);
|
|
142
|
+
if (!m) return false;
|
|
143
|
+
const isSep = (c) => c === "\r" || c === "\n" || c === "\u2028" || c === "\u2029";
|
|
144
|
+
let failEnd = -1;
|
|
145
|
+
for (let i = m[0].length; i < l.length; i++) {
|
|
146
|
+
// A "." in the old pattern never crosses a line terminator, and split(/\r?\n/) leaves
|
|
147
|
+
// a bare CR, U+2028 and U+2029 inside a line, so a link behind one was not a match then
|
|
148
|
+
// and is not one now.
|
|
149
|
+
if (isSep(l[i])) return false;
|
|
150
|
+
if (l[i] !== "[") continue;
|
|
151
|
+
let j = i + 1, sep = false;
|
|
152
|
+
while (j < l.length && l[j] !== "]" && l[j] !== "[") { if (isSep(l[j])) sep = true; j++; }
|
|
153
|
+
if (j >= l.length) return false;
|
|
154
|
+
if (l[j] === "[") { if (sep) return false; i = j - 1; continue; }
|
|
155
|
+
if (l[j + 1] !== "(") { if (sep) return false; i = j; continue; }
|
|
156
|
+
const k = j + 2;
|
|
157
|
+
if (k <= failEnd) { if (sep) return false; i = j + 1; continue; }
|
|
158
|
+
let e = k;
|
|
159
|
+
while (e < l.length && l[e] !== ")" && !/\s/.test(l[e])) e++;
|
|
160
|
+
if (e > k && l[e] === ")") return true;
|
|
161
|
+
if (sep) return false;
|
|
162
|
+
if (l[e] !== ")") failEnd = e;
|
|
163
|
+
i = j + 1;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
106
168
|
function redirectFailDetail(f) {
|
|
107
169
|
if (f.reason === "off-host") return "redirects to " + f.location + ", a different host; llms.txt is host-scoped, so validate that host directly";
|
|
108
170
|
if (f.reason === "unsafe-target") return "redirects to an unsupported target (" + f.location + "); only https redirects to the same site are followed";
|
|
@@ -160,7 +222,7 @@ export function validateLlmsTxt(f) {
|
|
|
160
222
|
for (const l of lines) {
|
|
161
223
|
if (/^## /.test(l)) { inSection = true; counted = false; continue; }
|
|
162
224
|
if (/^# /.test(l)) { inSection = false; continue; }
|
|
163
|
-
if (inSection && !counted &&
|
|
225
|
+
if (inSection && !counted && listItemHasLink(l)) { sectionsWithList++; counted = true; }
|
|
164
226
|
}
|
|
165
227
|
}
|
|
166
228
|
if (h2Count > 0 && sectionsWithList > 0) {
|
|
@@ -170,12 +232,12 @@ export function validateLlmsTxt(f) {
|
|
|
170
232
|
} else {
|
|
171
233
|
add("sections", "warn", "H2 sections group the content", "no H2 sections found; sections are the convention for grouping links");
|
|
172
234
|
}
|
|
173
|
-
const links =
|
|
235
|
+
const links = collectLinks(f.text);
|
|
174
236
|
// An entry an agent can use has a name and a target with a host. An empty name and a
|
|
175
237
|
// bare "https://" both counted as valid absolute links until 2026-08-29.
|
|
176
|
-
const named = links.filter((m) => m
|
|
238
|
+
const named = links.filter((m) => m.name.trim() !== "");
|
|
177
239
|
const unnamed = links.length - named.length;
|
|
178
|
-
const absolute = named.filter((m) => /^https?:\/\/[^/\s?#]+/.test(m
|
|
240
|
+
const absolute = named.filter((m) => /^https?:\/\/[^/\s?#]+/.test(m.target)).length;
|
|
179
241
|
if (links.length === 0) {
|
|
180
242
|
add("links", "warn", "Markdown links an agent can follow", "no markdown links found");
|
|
181
243
|
} else if (unnamed > 0) {
|
|
@@ -183,7 +245,7 @@ export function validateLlmsTxt(f) {
|
|
|
183
245
|
} else if (absolute === named.length) {
|
|
184
246
|
add("links", "pass", "Markdown links an agent can follow", named.length + " link" + (named.length === 1 ? "" : "s") + ", all absolute URLs");
|
|
185
247
|
} else {
|
|
186
|
-
const relativeCount = named.filter((m) => !/^[a-z][a-z0-9+.-]*:/i.test(m
|
|
248
|
+
const relativeCount = named.filter((m) => !/^[a-z][a-z0-9+.-]*:/i.test(m.target)).length;
|
|
187
249
|
const hostless = named.length - absolute - relativeCount;
|
|
188
250
|
add("links", "warn", "Markdown links an agent can follow", named.length + " links, " + relativeCount + " relative" + (hostless > 0 ? " and " + hostless + " with a scheme but no host" : "") + "; absolute URLs travel better when the file is read out of context");
|
|
189
251
|
}
|