@aia-matrix/llms-txt-validator 0.0.0-stage → 1.0.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.
@@ -0,0 +1,236 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { checkFormat } from "./format.js";
5
+ import { Client, pooled, networkMessage } from "./network.js";
6
+ import { relations, hasRel } from "./discovery.js";
7
+ import { markdownCandidates, pageCandidates } from "./markdown-versions.js";
8
+ import { scopedCandidates, applicable } from "./scope.js";
9
+ import { SPEC } from "./spec.js";
10
+ export async function validate(target, options = {}) {
11
+ const max = options.maxLinks ?? 50;
12
+ const timeout = options.timeout ?? 10000;
13
+ if (!Number.isInteger(max) ||
14
+ max < 0 ||
15
+ !Number.isInteger(timeout) ||
16
+ timeout < 1)
17
+ throw Error("maxLinks must be a nonnegative integer; timeout must be a positive integer");
18
+ const remote = /^https?:\/\//i.test(target);
19
+ const network = remote && options.network !== false;
20
+ const results = [];
21
+ const add = (id, status, message, url, specRef = SPEC.proposal) => results.push({ id, status, message, url, specRef });
22
+ let url = remote ? new URL(target).href : pathToFileURL(resolve(target)).href;
23
+ if (remote) {
24
+ const u = new URL(url);
25
+ if (!u.pathname.endsWith("/llms.txt")) {
26
+ u.pathname = u.pathname.replace(/\/$/, "") + "/llms.txt";
27
+ u.search = "";
28
+ u.hash = "";
29
+ }
30
+ url = u.href;
31
+ }
32
+ let rootOrigin = remote ? new URL(url).origin : "";
33
+ const sameOrigin = (resourceUrl) => new URL(resourceUrl).origin === rootOrigin;
34
+ const client = new Client(timeout);
35
+ const indexes = new Set();
36
+ const visited = new Set();
37
+ const checkedDiscovery = new Set();
38
+ const pendingIndexes = new Map();
39
+ const linkQueue = [];
40
+ const queued = new Set();
41
+ let truncated = false;
42
+ const isText = (r) => /^text\/(plain|markdown)\b/i.test(r.headers.get("content-type") ?? "");
43
+ const isHtml = (r) => /text\/html/i.test(r.headers.get("content-type") ?? "") ||
44
+ /^\s*(?:<!doctype\s+html|<html|<head|<body)/i.test(r.body.replace(/^\uFEFF/, ""));
45
+ const enqueue = (link, base) => {
46
+ try {
47
+ const u = new URL(link, base);
48
+ if (!["http:", "https:"].includes(u.protocol)) {
49
+ add("links.protocol", "fail", "Linked resource must use HTTP(S).", u.href);
50
+ return;
51
+ }
52
+ u.hash = "";
53
+ if (!queued.has(u.href)) {
54
+ queued.add(u.href);
55
+ if (queued.size <= max)
56
+ linkQueue.push(u.href);
57
+ else
58
+ truncated = true;
59
+ }
60
+ }
61
+ catch {
62
+ add("links.url", "fail", "Invalid link URL.", base);
63
+ }
64
+ };
65
+ const index = (indexUrl, resource) => {
66
+ const existing = pendingIndexes.get(indexUrl);
67
+ if (existing)
68
+ return existing;
69
+ visited.add(indexUrl);
70
+ const pending = (async () => {
71
+ try {
72
+ const r = resource ?? (await client.request(indexUrl));
73
+ add("file.http", r.status === 200 ? "pass" : "fail", `llms.txt returned HTTP ${r.status}.`, indexUrl);
74
+ if (r.status !== 200)
75
+ return;
76
+ if (indexUrl === url)
77
+ rootOrigin = new URL(r.url).origin;
78
+ indexes.add(indexUrl);
79
+ add("file.content-type", isText(r) ? "pass" : "fail", "llms.txt must use text/plain or text/markdown.", indexUrl);
80
+ add("file.not-html", isHtml(r) ? "fail" : "pass", "llms.txt must not be an HTML document.", indexUrl);
81
+ if (isHtml(r))
82
+ return;
83
+ const parsed = checkFormat(r.body, indexUrl);
84
+ results.push(...parsed.results);
85
+ for (const l of parsed.links)
86
+ enqueue(l, r.url);
87
+ }
88
+ catch (e) {
89
+ add("file.fetch", "fail", networkMessage(e, timeout), indexUrl);
90
+ }
91
+ })();
92
+ pendingIndexes.set(indexUrl, pending);
93
+ return pending;
94
+ };
95
+ if (!network) {
96
+ if (remote)
97
+ throw Error("--no-network requires a local file; a remote URL cannot be checked without fetching it");
98
+ const text = await readFile(resolve(target), "utf8");
99
+ results.push(...checkFormat(text, url).results);
100
+ add("network.skipped", "info", "Format-only mode: network, discovery and scope checks were not performed.", url);
101
+ }
102
+ else {
103
+ await index(url);
104
+ const discoverScope = async (page) => {
105
+ if (!sameOrigin(page))
106
+ return;
107
+ for (const candidate of scopedCandidates(page)) {
108
+ if (visited.has(candidate)) {
109
+ await pendingIndexes.get(candidate);
110
+ continue;
111
+ }
112
+ try {
113
+ const r = await client.request(candidate);
114
+ if (r.status === 200)
115
+ await index(candidate, r);
116
+ else if (r.status !== 404 && r.status !== 410)
117
+ add("scope.unavailable", "warn", `Scope probe returned HTTP ${r.status}; coverage is incomplete.`, candidate);
118
+ }
119
+ catch (e) {
120
+ add("scope.unavailable", "warn", networkMessage(e, timeout), candidate);
121
+ }
122
+ }
123
+ };
124
+ const checkDiscovery = async (r, html) => {
125
+ if (!sameOrigin(r.url))
126
+ return;
127
+ const key = `${r.url} ${html ? "html" : "md"}`;
128
+ if (checkedDiscovery.has(key))
129
+ return;
130
+ checkedDiscovery.add(key);
131
+ await discoverScope(r.url);
132
+ const expected = applicable(r.url, [...indexes]);
133
+ const rels = relations(html ? r.body : "", r.headers.get("link"), r.url);
134
+ add("discovery.describedby", expected &&
135
+ rels.some((x) => hasRel(x, "describedby") && x.href === expected)
136
+ ? "pass"
137
+ : "warn", expected
138
+ ? `Expected describedby relation to ${expected}.`
139
+ : "No applicable llms.txt found.", r.url);
140
+ if (html) {
141
+ const alternate = rels.find((x) => hasRel(x, "alternate") && x.type?.toLowerCase() === "text/markdown");
142
+ add("discovery.alternate", alternate ? "pass" : "warn", "HTML should advertise a text/markdown alternate.", r.url);
143
+ let exists = false;
144
+ for (const candidate of markdownCandidates(r.url)) {
145
+ try {
146
+ const md = await client.request(candidate);
147
+ if (md.status === 200 && isText(md) && !isHtml(md)) {
148
+ exists = true;
149
+ await checkDiscovery(md, false);
150
+ break;
151
+ }
152
+ }
153
+ catch {
154
+ /* Try next allowed URL form. */
155
+ }
156
+ }
157
+ add("markdown.version", exists ? "pass" : "warn", "Check for a Markdown version at a v2 URL form.", r.url);
158
+ if (alternate && !markdownCandidates(r.url).includes(alternate.href))
159
+ add("discovery.alternate-url", "warn", "Alternate URL does not use a same-page v2 Markdown URL form.", r.url);
160
+ }
161
+ };
162
+ let offset = 0;
163
+ while (offset < linkQueue.length) {
164
+ const batch = linkQueue.slice(offset);
165
+ offset = linkQueue.length;
166
+ await pooled(batch, async (link) => {
167
+ try {
168
+ const head = await client.resolve(link);
169
+ add("links.http", head.status === 200 ? "pass" : "fail", `Linked resource returned HTTP ${head.status}.`, link);
170
+ if (head.status !== 200)
171
+ return;
172
+ if (sameOrigin(link) &&
173
+ sameOrigin(head.url) &&
174
+ new URL(link).pathname.endsWith("/llms.txt")) {
175
+ await index(link);
176
+ return;
177
+ }
178
+ const r = await client.request(link);
179
+ if (r.status !== 200) {
180
+ add("links.get", "fail", `GET returned HTTP ${r.status}.`, link);
181
+ return;
182
+ }
183
+ const html = isHtml(r);
184
+ add("links.resource-type", html || !isText(r) ? "warn" : "pass", html
185
+ ? "Linked resource is HTML rather than Markdown or text."
186
+ : "Check linked resource content type.", link);
187
+ if (!sameOrigin(link) || !sameOrigin(r.url))
188
+ return;
189
+ await checkDiscovery(r, html);
190
+ if (!html && new URL(r.url).pathname.endsWith(".md")) {
191
+ let found = false;
192
+ for (const page of pageCandidates(r.url)) {
193
+ try {
194
+ const p = await client.request(page);
195
+ if (p.status === 200 && isHtml(p)) {
196
+ found = true;
197
+ await checkDiscovery(p, true);
198
+ break;
199
+ }
200
+ }
201
+ catch {
202
+ /* Try next counterpart. */
203
+ }
204
+ }
205
+ if (!found)
206
+ add("markdown.source-page", "warn", "Could not locate the HTML counterpart; HTML discovery was not verified.", r.url);
207
+ }
208
+ }
209
+ catch (e) {
210
+ add("links.fetch", "fail", networkMessage(e, timeout), link);
211
+ }
212
+ });
213
+ }
214
+ if (truncated)
215
+ add("links.limit", "warn", `Link limit ${max} reached; remaining linked resources were not checked.`, url);
216
+ }
217
+ const unique = [
218
+ ...new Map(results.map((r) => [
219
+ JSON.stringify([r.id, r.url, r.status, r.message]),
220
+ r,
221
+ ])).values(),
222
+ ];
223
+ unique.sort((a, b) => a.url.localeCompare(b.url) ||
224
+ a.id.localeCompare(b.id) ||
225
+ a.message.localeCompare(b.message));
226
+ const summary = { pass: 0, info: 0, warn: 0, fail: 0 };
227
+ for (const r of unique)
228
+ summary[r.status]++;
229
+ return {
230
+ version: "1.0",
231
+ url,
232
+ checkedAt: new Date().toISOString(),
233
+ summary,
234
+ results: unique,
235
+ };
236
+ }
@@ -0,0 +1 @@
1
+ export declare const VERSION = "1.0.0";
@@ -0,0 +1,2 @@
1
+ // Generated from package.json by scripts/generate-version.mjs.
2
+ export const VERSION = "1.0.0";
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@aia-matrix/llms-txt-validator",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Validate llms.txt format, linked resources, Markdown discovery and scoped indexes",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "bin": {
11
+ "llms-txt-validator": "dist/cli.js"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE",
23
+ "CHANGELOG.md",
24
+ "THIRD_PARTY_LICENSES.md"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/aiamatrix/llms-txt-validator.git"
29
+ },
30
+ "homepage": "https://aiamatrix.com",
31
+ "scripts": {
32
+ "build": "tsc && esbuild src/action.ts --bundle --platform=node --target=node24 --format=cjs --outfile=dist/action.cjs --metafile=.action-metafile.json",
33
+ "test": "vitest run",
34
+ "lint": "eslint src tests",
35
+ "prepublishOnly": "npm run lint && npm test && npm run build",
36
+ "format": "prettier --write src tests *.md *.json *.mjs .github examples action.yml",
37
+ "prebuild": "node scripts/generate-version.mjs"
38
+ },
39
+ "dependencies": {
40
+ "markdown-it": "^14.1.0",
41
+ "parse5": "^7.2.1"
42
+ },
43
+ "devDependencies": {
44
+ "@types/markdown-it": "^14.2.0",
45
+ "@types/node": "^22",
46
+ "esbuild": "^0.28.2",
47
+ "eslint": "^10.12.0",
48
+ "prettier": "^3.9.9",
49
+ "typescript": "^6.0.3",
50
+ "typescript-eslint": "^8.71.1",
51
+ "vitest": "^5.0.3"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ }
56
+ }