@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 @@
1
+ export {};
package/dist/action.js ADDED
@@ -0,0 +1,35 @@
1
+ import { appendFile } from "node:fs/promises";
2
+ import { validate } from "./validator.js";
3
+ import { human, exitCode } from "./report.js";
4
+ async function main() {
5
+ const url = process.env.INPUT_URL;
6
+ if (!url)
7
+ throw Error("url input is required");
8
+ const failOn = process.env["INPUT_FAIL-ON"] ?? "error";
9
+ if (failOn !== "warn" && failOn !== "error")
10
+ throw Error("fail-on must be warn or error");
11
+ const numeric = (name, fallback, minimum) => {
12
+ const raw = process.env[`INPUT_${name.toUpperCase()}`] ?? fallback;
13
+ const value = Number(raw);
14
+ if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < minimum)
15
+ throw Error(`${name} must be ${minimum === 0 ? "a nonnegative" : "a positive"} integer`);
16
+ return value;
17
+ };
18
+ const maxLinks = numeric("max-links", "50", 0);
19
+ const timeout = numeric("timeout", "10000", 1);
20
+ const report = await validate(url, {
21
+ failOn,
22
+ maxLinks,
23
+ timeout,
24
+ });
25
+ console.log(human(report));
26
+ if (process.env.GITHUB_STEP_SUMMARY) {
27
+ const escape = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
28
+ await appendFile(process.env.GITHUB_STEP_SUMMARY, `## llms.txt validation\n\n${report.summary.pass} pass · ${report.summary.info} info · ${report.summary.warn} warn · ${report.summary.fail} fail\n\n<pre>${escape(human(report))}</pre>\n`);
29
+ }
30
+ process.exitCode = exitCode(report, failOn);
31
+ }
32
+ main().catch((e) => {
33
+ console.error(`Tool error: ${e instanceof Error ? e.message : String(e)}`);
34
+ process.exitCode = 2;
35
+ });
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { validate } from "./validator.js";
4
+ import { human, exitCode } from "./report.js";
5
+ import { VERSION } from "./spec.js";
6
+ let json = process.argv.slice(2).includes("--json");
7
+ try {
8
+ const { values, positionals } = parseArgs({
9
+ allowPositionals: true,
10
+ options: {
11
+ json: { type: "boolean" },
12
+ "max-links": { type: "string" },
13
+ timeout: { type: "string" },
14
+ "fail-on": { type: "string" },
15
+ "no-network": { type: "boolean" },
16
+ help: { type: "boolean", short: "h" },
17
+ version: { type: "boolean", short: "v" },
18
+ },
19
+ });
20
+ json = values.json ?? false;
21
+ if (values.help) {
22
+ console.log("Usage: llms-txt-validator <url|file> [--json] [--max-links 50] [--timeout 10000] [--fail-on warn|error] [--no-network]");
23
+ }
24
+ else if (values.version) {
25
+ console.log(VERSION);
26
+ }
27
+ else {
28
+ if (positionals.length !== 1)
29
+ throw Error("Provide exactly one website URL or local llms.txt file");
30
+ const failOn = values["fail-on"] ?? "error";
31
+ if (failOn !== "warn" && failOn !== "error")
32
+ throw Error("--fail-on must be warn or error");
33
+ const report = await validate(positionals[0], {
34
+ maxLinks: values["max-links"] === undefined ? 50 : Number(values["max-links"]),
35
+ timeout: values.timeout === undefined ? 10000 : Number(values.timeout),
36
+ failOn,
37
+ network: !values["no-network"],
38
+ });
39
+ console.log(json ? JSON.stringify(report, null, 2) : human(report));
40
+ process.exitCode = exitCode(report, failOn);
41
+ }
42
+ }
43
+ catch (e) {
44
+ if (json)
45
+ console.log(JSON.stringify({
46
+ version: "1.0",
47
+ error: { message: e instanceof Error ? e.message : String(e) },
48
+ }));
49
+ else
50
+ console.error(`Tool error: ${e instanceof Error ? e.message : String(e)}`);
51
+ process.exitCode = 2;
52
+ }
@@ -0,0 +1,7 @@
1
+ export interface Relation {
2
+ href: string;
3
+ rel: string;
4
+ type?: string;
5
+ }
6
+ export declare function relations(body: string, header: string | null, base: string): Relation[];
7
+ export declare const hasRel: (r: Relation, name: string) => boolean;
@@ -0,0 +1,55 @@
1
+ import { parse } from "parse5";
2
+ export function relations(body, header, base) {
3
+ const out = [];
4
+ const add = (href, rel, type) => {
5
+ try {
6
+ out.push({ href: new URL(href, base).href, rel, type });
7
+ }
8
+ catch {
9
+ /* Invalid relation is not usable. */
10
+ }
11
+ };
12
+ for (const match of (header ?? "").matchAll(/<([^>]+)>((?:\s*;\s*[\w-]+\s*=\s*(?:"[^"]*"|[^;,]+))*)/g)) {
13
+ const attrs = new Map();
14
+ for (const a of match[2].matchAll(/;\s*([\w-]+)\s*=\s*(?:"([^"]*)"|([^;,]+))/g))
15
+ attrs.set(a[1].toLowerCase(), (a[2] ?? a[3]).trim());
16
+ add(match[1], attrs.get("rel") ?? "", attrs.get("type"));
17
+ }
18
+ const document = parse(body);
19
+ let effective = base;
20
+ const walk = (node, visit) => {
21
+ const n = node;
22
+ if (n.tagName)
23
+ visit(n.tagName, new Map((n.attrs ?? []).map((a) => [a.name, a.value])));
24
+ for (const child of n.childNodes ?? [])
25
+ walk(child, visit);
26
+ };
27
+ let foundBase = false;
28
+ walk(document, (tag, a) => {
29
+ if (tag === "base" && a.has("href") && !foundBase) {
30
+ try {
31
+ effective = new URL(a.get("href"), base).href;
32
+ foundBase = true;
33
+ }
34
+ catch {
35
+ /* Ignore invalid base. */
36
+ }
37
+ }
38
+ });
39
+ walk(document, (tag, a) => {
40
+ if (tag === "link" && a.has("href")) {
41
+ try {
42
+ out.push({
43
+ href: new URL(a.get("href"), effective).href,
44
+ rel: a.get("rel") ?? "",
45
+ type: a.get("type"),
46
+ });
47
+ }
48
+ catch {
49
+ /* Ignore invalid link. */
50
+ }
51
+ }
52
+ });
53
+ return out;
54
+ }
55
+ export const hasRel = (r, name) => r.rel.toLowerCase().split(/\s+/).includes(name);
@@ -0,0 +1,6 @@
1
+ import type { Result } from "./types.js";
2
+ export interface Parsed {
3
+ results: Result[];
4
+ links: string[];
5
+ }
6
+ export declare function checkFormat(input: string, url: string): Parsed;
package/dist/format.js ADDED
@@ -0,0 +1,117 @@
1
+ import MarkdownIt from "markdown-it";
2
+ import { SPEC } from "./spec.js";
3
+ const parser = new MarkdownIt();
4
+ export function checkFormat(input, url) {
5
+ const text = input.replace(/^\uFEFF/, "");
6
+ const results = [];
7
+ const links = [];
8
+ const add = (id, ok, message) => results.push({
9
+ id,
10
+ status: ok ? "pass" : "fail",
11
+ message,
12
+ specRef: SPEC.format,
13
+ url,
14
+ });
15
+ const tokens = parser.parse(text, {});
16
+ const heads = tokens.filter((t) => t.type === "heading_open");
17
+ add("format.h1", heads.filter((t) => t.tag === "h1").length === 1 &&
18
+ tokens[0]?.type === "heading_open" &&
19
+ tokens[0]?.tag === "h1" &&
20
+ !!tokens[1]?.content.trim(), "File must begin with exactly one nonempty H1.");
21
+ add("format.heading-levels", heads.every((t) => t.tag === "h1" || t.tag === "h2"), "Only H1 and H2 headings are allowed.");
22
+ let section = false;
23
+ let count = 0;
24
+ let sectionNumber = 0;
25
+ let sectionBad = false;
26
+ let preambleStarted = false;
27
+ let summarySeen = false;
28
+ const warn = (id, message) => results.push({ id, status: "warn", message, specRef: SPEC.format, url });
29
+ const close = () => {
30
+ if (section)
31
+ add(`format.section.${sectionNumber}`, count > 0 && !sectionBad, "Each H2 section must contain nonempty link-list items.");
32
+ };
33
+ for (let i = 0; i < tokens.length; i++) {
34
+ const t = tokens[i];
35
+ if (t.level !== 0)
36
+ continue;
37
+ if (t.type === "heading_open") {
38
+ if (t.tag === "h2") {
39
+ close();
40
+ section = true;
41
+ count = 0;
42
+ sectionBad = !tokens[i + 1]?.content.trim();
43
+ sectionNumber++;
44
+ }
45
+ i += 2;
46
+ continue;
47
+ }
48
+ if (!section) {
49
+ if (t.type === "blockquote_open") {
50
+ if (preambleStarted || summarySeen)
51
+ warn("format.summary-order", "An optional summary should precede the non-heading preamble.");
52
+ else
53
+ add("format.summary-order", true, "Summary precedes the non-heading preamble.");
54
+ summarySeen = true;
55
+ }
56
+ else if (t.type.endsWith("_open") ||
57
+ t.type === "fence" ||
58
+ t.type === "html_block")
59
+ preambleStarted = true;
60
+ continue;
61
+ }
62
+ if (t.type === "bullet_list_open" || t.type === "ordered_list_open") {
63
+ const end = tokens.findIndex((x, j) => j > i &&
64
+ x.type === t.type.replace("_open", "_close") &&
65
+ x.level === 0);
66
+ for (let j = i + 1; j < end; j++) {
67
+ if (tokens[j].type !== "list_item_open")
68
+ continue;
69
+ count++;
70
+ const level = tokens[j].level;
71
+ const itemEnd = tokens.findIndex((x, k) => k > j && x.type === "list_item_close" && x.level === level);
72
+ const inlines = tokens
73
+ .slice(j + 1, itemEnd)
74
+ .filter((x) => x.type === "inline");
75
+ const children = inlines.flatMap((x) => x.children ?? []);
76
+ const firstLink = children.find((x) => x.type === "link_open");
77
+ // Preserve links even when the item's arrangement needs a warning.
78
+ for (const child of children.filter((x) => x.type === "link_open")) {
79
+ const href = child.attrGet("href");
80
+ if (href)
81
+ links.push(href);
82
+ }
83
+ if (!firstLink)
84
+ sectionBad = true;
85
+ else if (children[0]?.type !== "link_open")
86
+ warn("format.item-prefix", "A file-list item should start with its link.");
87
+ else {
88
+ const linkEnd = children.findIndex((x) => x.type === "link_close");
89
+ const suffix = children
90
+ .slice(linkEnd + 1)
91
+ .map((x) => x.content)
92
+ .join("")
93
+ .trim();
94
+ if (suffix && !suffix.startsWith(":"))
95
+ warn("format.item-notes", "Notes after a file-list link should start with a colon.");
96
+ }
97
+ }
98
+ i = end;
99
+ continue;
100
+ }
101
+ if (!t.type.endsWith("_close"))
102
+ warn("format.section-prose", "H2 sections should contain file lists; additional prose is present.");
103
+ }
104
+ close();
105
+ const bytes = Buffer.byteLength(input);
106
+ const size = bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KiB` : `${bytes} B`;
107
+ results.push({
108
+ id: "file.size",
109
+ status: bytes > 50 * 1024 ? "warn" : "info",
110
+ message: bytes > 50 * 1024
111
+ ? `llms.txt is ${size}, above the 50 KiB guidance; agents may truncate or skip it (not a spec requirement).`
112
+ : `llms.txt is ${size} (guidance: 50 KiB or less; not a spec requirement).`,
113
+ specRef: SPEC.proposal,
114
+ url,
115
+ });
116
+ return { results, links };
117
+ }
@@ -0,0 +1,4 @@
1
+ export { validate } from "./validator.js";
2
+ export { checkFormat } from "./format.js";
3
+ export { exitCode } from "./report.js";
4
+ export type { Options, Report, Result, Status } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { validate } from "./validator.js";
2
+ export { checkFormat } from "./format.js";
3
+ export { exitCode } from "./report.js";
@@ -0,0 +1,2 @@
1
+ export declare function markdownCandidates(page: string): string[];
2
+ export declare function pageCandidates(markdown: string): string[];
@@ -0,0 +1,32 @@
1
+ export function markdownCandidates(page) {
2
+ const u = new URL(page);
3
+ const path = u.pathname;
4
+ const paths = path.endsWith("/")
5
+ ? [path + "index.md", path + "index.html.md"]
6
+ : [path + ".md", path.replace(/\.[^/.]+$/, ".md")];
7
+ return [
8
+ ...new Set(paths.map((p) => {
9
+ const v = new URL(u);
10
+ v.pathname = p;
11
+ return v.href;
12
+ })),
13
+ ];
14
+ }
15
+ export function pageCandidates(markdown) {
16
+ const u = new URL(markdown);
17
+ if (!u.pathname.endsWith(".md"))
18
+ return [];
19
+ const path = u.pathname.slice(0, -3);
20
+ const paths = path.endsWith("/index")
21
+ ? [path.slice(0, -5), path + ".html"]
22
+ : path.endsWith("/index.html")
23
+ ? [path.slice(0, -10), path]
24
+ : [path, ...(!/\.[^/]+$/.test(path) ? [path + ".html"] : [])];
25
+ return [
26
+ ...new Set(paths.map((p) => {
27
+ const v = new URL(u);
28
+ v.pathname = p;
29
+ return v.href;
30
+ })),
31
+ ];
32
+ }
@@ -0,0 +1,11 @@
1
+ import type { Resource } from "./types.js";
2
+ export declare function networkMessage(error: unknown, timeout?: number): string;
3
+ export declare class Client {
4
+ private timeout;
5
+ private cache;
6
+ constructor(timeout: number);
7
+ request(url: string, method?: string): Promise<Resource>;
8
+ private fetch;
9
+ resolve(url: string): Promise<Resource>;
10
+ }
11
+ export declare function pooled<T>(items: T[], work: (item: T) => Promise<void>): Promise<void>;
@@ -0,0 +1,107 @@
1
+ import { VERSION } from "./spec.js";
2
+ export function networkMessage(error, timeout) {
3
+ const e = error;
4
+ if (e?.name === "AbortError" || e?.name === "TimeoutError")
5
+ return timeout ? `timed out after ${timeout} ms` : "timed out";
6
+ const message = e?.message ?? String(error);
7
+ const cause = [e?.cause?.code, e?.cause?.message].filter(Boolean).join(" ");
8
+ return cause ? `${message}: ${cause}` : message;
9
+ }
10
+ export class Client {
11
+ timeout;
12
+ cache = new Map();
13
+ constructor(timeout) {
14
+ this.timeout = timeout;
15
+ }
16
+ async request(url, method = "GET") {
17
+ const key = method + " " + url;
18
+ const existing = this.cache.get(key);
19
+ if (existing)
20
+ return existing;
21
+ const pending = this.fetch(url, method);
22
+ this.cache.set(key, pending);
23
+ return pending;
24
+ }
25
+ async fetch(url, method) {
26
+ const controller = new AbortController();
27
+ const timer = setTimeout(() => controller.abort(), this.timeout);
28
+ try {
29
+ let current = url;
30
+ const seen = new Set();
31
+ for (let n = 0; n <= 10; n++) {
32
+ if (seen.has(current))
33
+ throw Error("Redirect loop");
34
+ seen.add(current);
35
+ const parsed = new URL(current);
36
+ if (!["http:", "https:"].includes(parsed.protocol))
37
+ throw Error("Only HTTP(S) URLs are supported");
38
+ const response = await fetch(current, {
39
+ method,
40
+ redirect: "manual",
41
+ signal: controller.signal,
42
+ headers: {
43
+ "User-Agent": `aiamatrix-llms-txt-validator/${VERSION} (+https://github.com/aiamatrix/llms-txt-validator)`,
44
+ },
45
+ });
46
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
47
+ const location = response.headers.get("location");
48
+ await response.body?.cancel();
49
+ if (!location)
50
+ throw Error("Redirect missing Location");
51
+ current = new URL(location, current).href;
52
+ continue;
53
+ }
54
+ const reader = response.body?.getReader();
55
+ const chunks = [];
56
+ let bytes = 0;
57
+ if (reader)
58
+ while (true) {
59
+ const { done, value } = await reader.read();
60
+ if (done)
61
+ break;
62
+ bytes += value.length;
63
+ if (bytes > 2 * 1024 * 1024) {
64
+ await reader.cancel();
65
+ throw Error("Response exceeds 2 MiB safety limit");
66
+ }
67
+ chunks.push(value);
68
+ }
69
+ return {
70
+ url: current,
71
+ status: response.status,
72
+ headers: response.headers,
73
+ body: Buffer.concat(chunks).toString("utf8"),
74
+ };
75
+ }
76
+ throw Error("More than 10 redirects");
77
+ }
78
+ catch (e) {
79
+ throw new Error(controller.signal.aborted
80
+ ? `timed out after ${this.timeout} ms`
81
+ : networkMessage(e, this.timeout));
82
+ }
83
+ finally {
84
+ clearTimeout(timer);
85
+ }
86
+ }
87
+ async resolve(url) {
88
+ try {
89
+ const head = await this.request(url, "HEAD");
90
+ if (head.status === 200)
91
+ return head;
92
+ }
93
+ catch {
94
+ /* Retry with GET. */
95
+ }
96
+ return this.request(url);
97
+ }
98
+ }
99
+ export async function pooled(items, work) {
100
+ let next = 0;
101
+ await Promise.all(Array.from({ length: Math.min(5, items.length) }, async () => {
102
+ while (next < items.length) {
103
+ const item = items[next++];
104
+ await work(item);
105
+ }
106
+ }));
107
+ }
@@ -0,0 +1,3 @@
1
+ import type { Report } from "./types.js";
2
+ export declare function exitCode(report: Report, failOn?: "warn" | "error"): 0 | 1;
3
+ export declare function human(report: Report): string;
package/dist/report.js ADDED
@@ -0,0 +1,17 @@
1
+ export function exitCode(report, failOn = "error") {
2
+ return report.summary.fail > 0 ||
3
+ (failOn === "warn" && report.summary.warn > 0)
4
+ ? 1
5
+ : 0;
6
+ }
7
+ export function human(report) {
8
+ const groups = new Map();
9
+ for (const r of report.results) {
10
+ const key = r.id.split(".")[0];
11
+ const group = groups.get(key) ?? [];
12
+ group.push(` ${r.status.toUpperCase()} ${r.id}: ${r.message}\n ${r.url}\n ${r.specRef}`);
13
+ groups.set(key, group);
14
+ }
15
+ return ([...groups].map(([k, v]) => `${k}\n${v.join("\n")}`).join("\n\n") +
16
+ `\n\nSummary: ${report.summary.pass} pass, ${report.summary.info} info, ${report.summary.warn} warn, ${report.summary.fail} fail\nGet a full AI-readiness report: https://aiamatrix.com`);
17
+ }
@@ -0,0 +1,2 @@
1
+ export declare function scopedCandidates(page: string): string[];
2
+ export declare function applicable(page: string, indexes: string[]): string | undefined;
package/dist/scope.js ADDED
@@ -0,0 +1,20 @@
1
+ export function scopedCandidates(page) {
2
+ const u = new URL(page);
3
+ const dirs = u.pathname
4
+ .slice(0, u.pathname.lastIndexOf("/") + 1)
5
+ .split("/")
6
+ .filter(Boolean);
7
+ const result = [];
8
+ for (let i = dirs.length; i >= 0; i--)
9
+ result.push(new URL("/" + dirs.slice(0, i).join("/") + (i ? "/" : "") + "llms.txt", u.origin).href);
10
+ return result;
11
+ }
12
+ export function applicable(page, indexes) {
13
+ const p = new URL(page);
14
+ return indexes
15
+ .filter((s) => {
16
+ const u = new URL(s);
17
+ return (u.origin === p.origin && p.pathname.startsWith(u.pathname.slice(0, -8)));
18
+ })
19
+ .sort((a, b) => b.length - a.length)[0];
20
+ }
package/dist/spec.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { VERSION } from "./version.js";
2
+ export declare const SPEC: {
3
+ format: string;
4
+ proposal: string;
5
+ optional: string;
6
+ };
package/dist/spec.js ADDED
@@ -0,0 +1,6 @@
1
+ export { VERSION } from "./version.js";
2
+ export const SPEC = {
3
+ format: "https://llmstxt.org/#format",
4
+ proposal: "https://llmstxt.org/#proposal",
5
+ optional: "https://llmstxt.org/changes.html#v2-august-2026",
6
+ };
@@ -0,0 +1,27 @@
1
+ export type Status = "pass" | "info" | "warn" | "fail";
2
+ export interface Result {
3
+ id: string;
4
+ status: Status;
5
+ message: string;
6
+ specRef: string;
7
+ url: string;
8
+ }
9
+ export interface Report {
10
+ version: "1.0";
11
+ url: string;
12
+ checkedAt: string;
13
+ summary: Record<Status, number>;
14
+ results: Result[];
15
+ }
16
+ export interface Options {
17
+ maxLinks?: number;
18
+ timeout?: number;
19
+ failOn?: "warn" | "error";
20
+ network?: boolean;
21
+ }
22
+ export interface Resource {
23
+ url: string;
24
+ status: number;
25
+ headers: Headers;
26
+ body: string;
27
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { Options, Report } from "./types.js";
2
+ export declare function validate(target: string, options?: Options): Promise<Report>;