static-llms-txt 0.0.0-stage → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OdysseyX LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,130 @@
1
- # Temporary Holding Version
1
+ # static-llms-txt
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Write `llms.txt` and `llms-full.txt` for any static site, from the HTML it already built.
4
+
5
+ Most llms.txt generators are tied to one framework and read its Markdown sources. This one reads the output folder, so it works after Eleventy, Hugo, Jekyll, Astro, Gatsby, Docusaurus, MkDocs, a hand-written site, or anything else that ends in `.html` files.
6
+
7
+ - **`llms.txt`**: an index in the [llmstxt.org](https://llmstxt.org) shape. Title, summary, one link per page with its description.
8
+ - **`llms-full.txt`**: the main content of every page as Markdown in one file, each page under its own heading with a `Source:` link.
9
+ - **No network, no API key, no config file.** One dependency (`node-html-parser`).
10
+ - Skips what should not be there: navigation, footers, scripts, `noindex` pages, redirects, 404 pages.
11
+ - Leaves an `llms.txt` you wrote by hand alone.
12
+
13
+ ## Use it from the command line
14
+
15
+ ```sh
16
+ npx static-llms-txt ./dist --site https://example.com
17
+ ```
18
+
19
+ Put it after your build:
20
+
21
+ ```json
22
+ {
23
+ "scripts": {
24
+ "build": "eleventy && static-llms-txt _site --site https://example.com"
25
+ }
26
+ }
27
+ ```
28
+
29
+ | Option | What it does |
30
+ | --- | --- |
31
+ | `--site <url>` | Public address of the site. Makes every link absolute. Without it, the page's canonical link is used. |
32
+ | `--title <text>` | Site title. Default: taken from the home page. |
33
+ | `--description <text>` | One-line summary. Default: the home page meta description. |
34
+ | `--content <selector>` | CSS selector of the main content. Default: `main`, then `article`, then `body`. |
35
+ | `--exclude <glob>` | Skip matching files, for example `"tags/**"`. Repeatable. |
36
+ | `--clean-urls` | Link `/about` instead of `/about.html`. |
37
+ | `--no-full` | Write `llms.txt` only. |
38
+ | `--force` | Overwrite an `llms.txt` this tool did not write. |
39
+ | `--ask-ai` | Also add an Ask AI box to every page. See below. |
40
+
41
+ ## Use it from code
42
+
43
+ ```js
44
+ import { generate } from "static-llms-txt";
45
+
46
+ const result = generate("./dist", {
47
+ site: "https://example.com",
48
+ exclude: ["tags/**", "drafts/**"],
49
+ });
50
+ console.log(result.pages.length, "pages", result.written);
51
+ ```
52
+
53
+ CommonJS works too: `const { generate } = require("static-llms-txt")`.
54
+
55
+ | Function | What it does |
56
+ | --- | --- |
57
+ | `generate(dir, options)` | Scan a folder and write both files into it. Returns `{ pages, llms, llmsFull, written, skipped }`. |
58
+ | `scan(dir, options)` | Read every page. Returns `[{ url, path, title, description, sections, text }]`. |
59
+ | `build(pages, options)` | Turn a list of pages (from `scan`, or your own `{ url, title, html }` objects) into the text of both files, without touching the disk. |
60
+ | `readHtml(html, options)` | One HTML document to title, description and Markdown content. |
61
+ | `inject(dir, options)` / `scriptTag(options)` | The optional Ask AI part. |
62
+
63
+ Options for `generate` and `scan`: `site`, `title`, `description`, `content`, `remove` (extra selectors to drop), `exclude` (globs), `cleanUrls`, `minLength` (default 40 characters), `full`, `force`, `out`, `write`. Types are included.
64
+
65
+ ## What the output looks like
66
+
67
+ `llms.txt`
68
+
69
+ ```md
70
+ # Acme Docs
71
+
72
+ > Acme makes anvils.
73
+
74
+ ## Pages
75
+
76
+ - [Acme](https://acme.dev/): Acme makes anvils.
77
+ - [Guide](https://acme.dev/guide/): How to start.
78
+
79
+ ## Optional
80
+
81
+ - [Full text of every page](https://acme.dev/llms-full.txt)
82
+ ```
83
+
84
+ `llms-full.txt`
85
+
86
+ ```md
87
+ # Guide
88
+
89
+ Source: https://acme.dev/guide/
90
+
91
+ Start here with the guide.
92
+
93
+ ## Step one
94
+
95
+ Source: https://acme.dev/guide/#step-one
96
+
97
+ Unpack the anvil carefully.
98
+ ```
99
+
100
+ ## Optional: an Ask AI box that answers from llms-full.txt
101
+
102
+ Once the site has an `llms-full.txt`, a single script tag can answer visitor questions from it. `--ask-ai` adds that tag to every page:
103
+
104
+ ```sh
105
+ npx static-llms-txt ./dist --site https://example.com --ask-ai --key YOUR_ONETAG_KEY
106
+ ```
107
+
108
+ The box is [OneTag AI](https://onetagai.com/?ref=static-llms-txt)'s open-source `ask-docs.js`. It finds the pages that match the question in `llms-full.txt`, sends them with the question to the OneTag AI API, and shows the answer with links to the pages it used. There is no backend to run.
109
+
110
+ The key is a site key. It is public by design, like an analytics ID, and it ends up in the built HTML. The free plan is 1,000,000 tokens a month with a hard cap: when it runs out the box says so, and no bill can appear. Get one without opening a browser:
111
+
112
+ ```sh
113
+ curl -X POST https://onetagai.com/api/signup -H "Content-Type: application/json" -d '{"email":"you@example.com"}'
114
+ ```
115
+
116
+ Without `--key` the tool uses a demo key that answers on localhost only, so you can try it before signing up. Everything above this section works without OneTag AI.
117
+
118
+ ## Framework plugins built on this
119
+
120
+ - `eleventy-plugin-onetag` for Eleventy
121
+ - `docusaurus-plugin-onetag` for Docusaurus
122
+ - `gatsby-plugin-onetag` for Gatsby
123
+ - `hexo-onetag` for Hexo
124
+ - `vitepress-plugin-onetag` for VitePress
125
+ - `nuxt-onetag` for Nuxt
126
+ - [`astro-onetag`](https://www.npmjs.com/package/astro-onetag) and [`starlight-onetag`](https://www.npmjs.com/package/starlight-onetag) for Astro
127
+
128
+ ## License
129
+
130
+ MIT
package/cli.cjs ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ const path = require("node:path");
4
+ const { generate, inject } = require("./index.cjs");
5
+
6
+ const HELP = `static-llms-txt: write llms.txt and llms-full.txt from a built static site.
7
+
8
+ Usage
9
+ npx static-llms-txt <folder> [options]
10
+
11
+ Options
12
+ --site <url> Public address of the site, e.g. https://example.com. Used for absolute links.
13
+ --title <text> Site title. Default: taken from the home page.
14
+ --description <text> One-line summary. Default: the home page meta description.
15
+ --content <selector> CSS selector of the main content. Default: main, article, then body.
16
+ --exclude <glob> Skip matching files, e.g. "tags/**". Repeatable.
17
+ --clean-urls Link /about instead of /about.html.
18
+ --no-full Write llms.txt only.
19
+ --force Overwrite an llms.txt this tool did not write.
20
+ --ask-ai Also add an "Ask AI" box to every page (one script tag, answers from llms-full.txt).
21
+ --key <key> OneTag AI site key for --ask-ai. Free: https://onetagai.com/?ref=static-llms-txt
22
+ -h, --help Show this help.
23
+ `;
24
+
25
+ function parseArgs(argv) {
26
+ const o = { exclude: [] };
27
+ const rest = [];
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i];
30
+ const next = () => { if (i + 1 >= argv.length) throw new Error("Missing value after " + a); return argv[++i]; };
31
+ if (a === "-h" || a === "--help") o.help = true;
32
+ else if (a === "--site") o.site = next();
33
+ else if (a === "--title") o.title = next();
34
+ else if (a === "--description") o.description = next();
35
+ else if (a === "--content") o.content = next();
36
+ else if (a === "--exclude") o.exclude.push(next());
37
+ else if (a === "--clean-urls") o.cleanUrls = true;
38
+ else if (a === "--no-full") o.full = false;
39
+ else if (a === "--force") o.force = true;
40
+ else if (a === "--ask-ai") o.askAi = true;
41
+ else if (a === "--key") o.key = next();
42
+ else if (a.startsWith("-")) throw new Error("Unknown option " + a + ". Run with --help to see the options.");
43
+ else rest.push(a);
44
+ }
45
+ o.dir = rest[0];
46
+ return o;
47
+ }
48
+
49
+ function main() {
50
+ let o;
51
+ try { o = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); process.exit(2); }
52
+ if (o.help || !o.dir) { console.log(HELP); process.exit(o.help ? 0 : 2); }
53
+ let result;
54
+ try { result = generate(o.dir, o); } catch (e) { console.error(e.message); process.exit(1); }
55
+ const dir = path.resolve(o.dir);
56
+ for (const f of result.written) console.log("wrote " + path.join(dir, f));
57
+ for (const f of result.skipped) console.log("kept " + path.join(dir, f) + " (not written by this tool; pass --force to replace it)");
58
+ console.log(result.pages.length + " pages");
59
+ if (!o.site && result.pages.some((p) => !/^https?:/.test(p.url))) console.log("Tip: pass --site https://your-site.com so the links are absolute.");
60
+ if (o.askAi) {
61
+ const changed = inject(dir, { key: o.key || "ot_demo000000000000000000000", ref: "static-llms-txt", exclude: o.exclude });
62
+ console.log("added the Ask AI script to " + changed.length + " pages");
63
+ if (!o.key) console.log("No --key given: the demo key answers on localhost only. Get a free key:\n curl -X POST https://onetagai.com/api/signup -H \"Content-Type: application/json\" -d '{\"email\":\"you@example.com\"}'");
64
+ }
65
+ }
66
+
67
+ main();
package/index.cjs ADDED
@@ -0,0 +1,356 @@
1
+ // static-llms-txt: write llms.txt and llms-full.txt for a static site from the HTML it already built.
2
+ // It reads a folder of .html files, keeps the main content of every page as Markdown, and writes
3
+ // llms.txt an index: one link per page (https://llmstxt.org)
4
+ // llms-full.txt every page in one file, one "# Title" + "Source: URL" block per page
5
+ // No network, no API key. The optional Ask AI part (askAi / inject) only adds a script tag.
6
+ "use strict";
7
+ const fs = require("node:fs");
8
+ const path = require("node:path");
9
+ const { parse } = require("node-html-parser");
10
+
11
+ const MARK = "<!-- generated by static-llms-txt -->";
12
+ const SCRIPT = "https://onetagai.com/ask-docs.js";
13
+ const DROP = "script,style,noscript,template,svg,canvas,iframe,object,embed,nav,footer,aside,form,button,select,dialog,[hidden],[aria-hidden=true],.sr-only,.visually-hidden";
14
+ const ROOTS = ["main", "[role=main]", "article", "#content", ".content", "body"];
15
+ const BLOCK = new Set(["p", "div", "section", "article", "main", "header", "figure", "figcaption", "details", "summary", "address", "dd", "dt", "dl", "tr", "table", "thead", "tbody", "tfoot", "ul", "ol", "li", "blockquote", "pre", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "br"]);
16
+
17
+ function decode(text) {
18
+ return String(text)
19
+ .replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
20
+ .replace(/&quot;/g, '"').replace(/&#0?39;|&apos;/g, "'")
21
+ .replace(/&#(\d+);/g, (m, n) => safeChar(Number(n), m)).replace(/&#x([0-9a-f]+);/gi, (m, n) => safeChar(parseInt(n, 16), m));
22
+ }
23
+ function safeChar(code, fallback) {
24
+ try { return String.fromCodePoint(code); } catch (e) { return fallback; }
25
+ }
26
+ const squash = (s) => decode(s).replace(/\s+/g, " ");
27
+ const tagOf = (node) => (node.rawTagName || "").toLowerCase();
28
+
29
+ function touchingLinks(node) {
30
+ const prev = node.previousSibling;
31
+ return !!prev && prev.nodeType === 1 && tagOf(node) === "a" && tagOf(prev) === "a";
32
+ }
33
+
34
+ // Inline text of a node: links and emphasis become their text, <code> keeps backticks.
35
+ function inline(node) {
36
+ if (node.nodeType === 3) return squash(node.rawText);
37
+ if (node.nodeType !== 1) return "";
38
+ const tag = tagOf(node);
39
+ if (tag === "br") return "\n";
40
+ if (tag === "img") return "";
41
+ // Block children (a heading inside a list item) and back-to-back links get a space, so words do not run together.
42
+ let inner = "";
43
+ for (const c of node.childNodes) {
44
+ const t = c.nodeType === 1 ? tagOf(c) : "";
45
+ const spaced = t && (BLOCK.has(t) || touchingLinks(c));
46
+ inner += (spaced ? " " : "") + inline(c) + (t && BLOCK.has(t) && t !== "br" ? " " : "");
47
+ }
48
+ if (tag === "code" && inner.trim()) return "`" + inner.trim() + "`";
49
+ return inner;
50
+ }
51
+
52
+ function codeBlock(node) {
53
+ const code = node.querySelector("code") || node;
54
+ const cls = (code.getAttribute("class") || "") + " " + (node.getAttribute("class") || "") + " " + (node.getAttribute("data-language") || node.getAttribute("data-lang") || "");
55
+ const lang = (/(?:language|lang|highlight-source)-([\w+#.-]+)/.exec(cls) || [])[1] || (node.getAttribute("data-language") || node.getAttribute("data-lang") || "");
56
+ // Line-per-element highlighters (Shiki, Expressive Code) put each line in its own block element.
57
+ const lines = code.querySelectorAll(".line, .ec-line");
58
+ const text = lines.length > 1 ? lines.map((l) => decode(l.text)).join("\n") : decode(code.text);
59
+ const body = text.replace(/\s+$/, "").replace(/^\n+/, "");
60
+ if (!body.trim()) return "";
61
+ const fence = body.includes("```") ? "~~~~" : "```";
62
+ return fence + lang + "\n" + body + "\n" + fence;
63
+ }
64
+
65
+ function table(node) {
66
+ const rows = node.querySelectorAll("tr").map((tr) => tr.querySelectorAll("th,td").map((c) => inline(c).replace(/\s+/g, " ").replace(/\|/g, "\\|").trim()));
67
+ const filled = rows.filter((r) => r.length);
68
+ if (!filled.length) return "";
69
+ const out = ["| " + filled[0].join(" | ") + " |", "| " + filled[0].map(() => "---").join(" | ") + " |"];
70
+ for (const r of filled.slice(1)) out.push("| " + r.join(" | ") + " |");
71
+ return out.join("\n");
72
+ }
73
+
74
+ function list(node, depth) {
75
+ const ordered = tagOf(node) === "ol";
76
+ const out = [];
77
+ let n = 0;
78
+ for (const li of node.childNodes) {
79
+ if (li.nodeType !== 1 || tagOf(li) !== "li") continue;
80
+ n++;
81
+ const own = [], nested = [];
82
+ for (const c of li.childNodes) {
83
+ const t = c.nodeType === 1 ? tagOf(c) : "";
84
+ if (t === "ul" || t === "ol") nested.push(list(c, depth + 1));
85
+ else if (t === "pre") nested.push(codeBlock(c).replace(/^/gm, " ".repeat(depth + 1)));
86
+ else own.push(t && BLOCK.has(t) ? " " + inline(c) + " " : (t && touchingLinks(c) ? " " : "") + inline(c));
87
+ }
88
+ const text = own.join("").replace(/\s+/g, " ").trim();
89
+ if (text) out.push(" ".repeat(depth) + (ordered ? n + ". " : "- ") + text);
90
+ for (const x of nested) if (x) out.push(x);
91
+ }
92
+ return out.join("\n");
93
+ }
94
+
95
+ // Markdown blocks of a content root. `shift` moves page headings down so a page's own
96
+ // sections sit under its "# Title" in llms-full.txt (h2 → ##, h3 → ###, …).
97
+ function blocks(root, ctx) {
98
+ const out = [];
99
+ let run = [];
100
+ const flush = () => {
101
+ const text = run.join("").replace(/[ \t]*\n[ \t]*/g, "\n").replace(/[ \t]+/g, " ").trim();
102
+ if (text) out.push(text);
103
+ run = [];
104
+ };
105
+ const walk = (node) => {
106
+ if (node.nodeType === 3) { run.push(squash(node.rawText)); return; }
107
+ if (node.nodeType !== 1) return;
108
+ const tag = tagOf(node);
109
+ if (!BLOCK.has(tag)) {
110
+ if (node.childNodes.some((c) => c.nodeType === 1 && BLOCK.has(tagOf(c)))) { node.childNodes.forEach(walk); return; }
111
+ run.push((touchingLinks(node) ? " " : "") + inline(node));
112
+ return;
113
+ }
114
+ if (tag === "br") { run.push("\n"); return; }
115
+ flush();
116
+ if (/^h[1-6]$/.test(tag)) {
117
+ const text = inline(node).replace(/\s+/g, " ").replace(/^#+\s*|\s*[#¶🔗]+$/g, "").trim();
118
+ if (!text) return;
119
+ const level = Number(tag[1]);
120
+ if (level === 1 && !ctx.h1) { ctx.h1 = text; return; } // the page title, written once by the caller
121
+ const id = node.getAttribute("id") || (node.querySelector("[id]") || { getAttribute: () => "" }).getAttribute("id") || "";
122
+ out.push({ heading: Math.max(2, level), text, id });
123
+ return;
124
+ }
125
+ if (tag === "pre") { const b = codeBlock(node); if (b) out.push(b); return; }
126
+ if (tag === "ul" || tag === "ol") { const b = list(node, 0); if (b) out.push(b); return; }
127
+ if (tag === "table") { const b = table(node); if (b) out.push(b); return; }
128
+ if (tag === "hr") return;
129
+ if (tag === "blockquote") {
130
+ const inner = blocks(node, ctx).filter((b) => typeof b === "string").join("\n\n");
131
+ if (inner) out.push(inner.replace(/^/gm, "> "));
132
+ return;
133
+ }
134
+ node.childNodes.forEach(walk);
135
+ flush();
136
+ };
137
+ root.childNodes.forEach(walk);
138
+ flush();
139
+ return out;
140
+ }
141
+
142
+ function meta(doc, selector) {
143
+ const el = doc.querySelector(selector);
144
+ return el ? decode(el.getAttribute("content") || "").replace(/\s+/g, " ").trim() : "";
145
+ }
146
+
147
+ // One HTML document → { title, description, canonical, noindex, redirect, sections, text }.
148
+ // sections: [{ heading, id, text }] where the first one (heading "") is the text before any h2.
149
+ function readHtml(html, options) {
150
+ const o = options || {};
151
+ const doc = parse(String(html), { comment: false, blockTextElements: { script: false, style: false, noscript: false } });
152
+ const robots = meta(doc, 'meta[name="robots"]') + " " + meta(doc, 'meta[name="googlebot"]');
153
+ const refresh = doc.querySelector('meta[http-equiv="refresh"], meta[http-equiv="Refresh"]');
154
+ const canonicalEl = doc.querySelector('link[rel="canonical"]');
155
+ const titleEl = doc.querySelector("title");
156
+ const docTitle = titleEl ? decode(titleEl.text).replace(/\s+/g, " ").trim() : "";
157
+ const info = {
158
+ title: "", docTitle,
159
+ siteName: meta(doc, 'meta[property="og:site_name"]'),
160
+ description: meta(doc, 'meta[name="description"]') || meta(doc, 'meta[property="og:description"]'),
161
+ canonical: (canonicalEl && canonicalEl.getAttribute("href")) || meta(doc, 'meta[property="og:url"]') || "",
162
+ noindex: /noindex/i.test(robots),
163
+ redirect: !!refresh,
164
+ lang: (doc.querySelector("html") && doc.querySelector("html").getAttribute("lang")) || "",
165
+ sections: [], text: "",
166
+ };
167
+ const selectors = [].concat(o.content || []).filter(Boolean).concat(ROOTS);
168
+ let root = null;
169
+ for (const s of selectors) { try { root = doc.querySelector(s); } catch (e) { root = null; } if (root) break; }
170
+ if (!root) return info;
171
+ for (const s of [DROP].concat(o.exclude || [])) {
172
+ let found = [];
173
+ try { found = root.querySelectorAll(s); } catch (e) { /* a selector the parser cannot read is skipped */ }
174
+ for (const el of found) el.remove();
175
+ }
176
+ const ctx = { h1: "" };
177
+ const parts = blocks(root, ctx);
178
+ let cur = { heading: "", level: 0, id: "", lines: [] };
179
+ const sections = [cur];
180
+ for (const b of parts) {
181
+ if (typeof b === "string") { cur.lines.push(b); continue; }
182
+ if (b.heading === 2) { cur = { heading: b.text, level: 2, id: b.id, lines: [] }; sections.push(cur); }
183
+ else cur.lines.push("#".repeat(b.heading) + " " + b.text);
184
+ }
185
+ info.sections = sections.map((s) => ({ heading: s.heading, id: s.id, text: s.lines.join("\n\n").trim() })).filter((s) => s.heading || s.text);
186
+ info.text = info.sections.map((s) => (s.heading ? "## " + s.heading + "\n\n" : "") + s.text).join("\n\n").trim();
187
+ info.title = ctx.h1 || meta(doc, 'meta[property="og:title"]') || docTitle;
188
+ return info;
189
+ }
190
+
191
+ // "a/**/b*.html" → RegExp, matched against paths relative to the site folder with "/" separators.
192
+ function globToRegExp(glob) {
193
+ const g = String(glob).replace(/^\.?\//, "");
194
+ let re = "";
195
+ for (let i = 0; i < g.length; i++) {
196
+ const c = g[i];
197
+ if (c === "*") {
198
+ if (g[i + 1] === "*") { re += ".*"; i++; if (g[i + 1] === "/") i++; }
199
+ else re += "[^/]*";
200
+ } else if (c === "?") re += "[^/]";
201
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
202
+ }
203
+ return new RegExp("^" + re + "$");
204
+ }
205
+
206
+ function walkHtml(dir) {
207
+ const out = [];
208
+ const visit = (abs, rel) => {
209
+ for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
210
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
211
+ const childAbs = path.join(abs, entry.name), childRel = rel ? rel + "/" + entry.name : entry.name;
212
+ if (entry.isDirectory()) visit(childAbs, childRel);
213
+ else if (/\.html?$/i.test(entry.name)) out.push(childRel);
214
+ }
215
+ };
216
+ visit(dir, "");
217
+ return out;
218
+ }
219
+
220
+ function urlPath(rel, cleanUrls) {
221
+ let p = "/" + rel;
222
+ if (/(^|\/)index\.html?$/i.test(rel)) p = p.replace(/index\.html?$/i, "");
223
+ else if (cleanUrls) p = p.replace(/\.html?$/i, "");
224
+ return p.split("/").map((seg) => encodeURIComponent(seg).replace(/%2B/g, "+")).join("/");
225
+ }
226
+
227
+ function joinUrl(site, p) {
228
+ if (!site) return "";
229
+ return String(site).replace(/\/+$/, "") + (p.startsWith("/") ? p : "/" + p);
230
+ }
231
+
232
+ // Home page first, then by depth, then by path: the order pages appear in both files.
233
+ function byPath(a, b) {
234
+ const da = a.path.split("/").filter(Boolean).length, db = b.path.split("/").filter(Boolean).length;
235
+ return da - db || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
236
+ }
237
+
238
+ // Read every page of a built site. Returns [{ file, path, url, title, description, sections, text }].
239
+ function scan(dir, options) {
240
+ const o = options || {};
241
+ const root = path.resolve(dir);
242
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error("static-llms-txt: " + root + " is not a folder. Pass the folder your site was built into.");
243
+ const excludes = [].concat(o.exclude || []).map(globToRegExp);
244
+ const pages = [];
245
+ for (const rel of walkHtml(root)) {
246
+ if (/(^|\/)(404|500)(\.html?|\/index\.html?)$/i.test(rel)) continue;
247
+ if (excludes.some((re) => re.test(rel))) continue;
248
+ const info = readHtml(fs.readFileSync(path.join(root, rel), "utf8"), { content: o.content, exclude: o.remove });
249
+ if (info.noindex || info.redirect) continue;
250
+ if (info.text.length < (o.minLength === undefined ? 40 : o.minLength)) continue;
251
+ const p = urlPath(rel, o.cleanUrls);
252
+ const url = o.site ? joinUrl(o.site, p) : /^https?:\/\//i.test(info.canonical) ? info.canonical : "";
253
+ pages.push({ file: rel, path: p, url, title: info.title || p, docTitle: info.docTitle, siteName: info.siteName, description: info.description, sections: info.sections, text: info.text });
254
+ }
255
+ return pages.sort(byPath);
256
+ }
257
+
258
+ const oneLine = (s) => String(s || "").replace(/\s+/g, " ").trim();
259
+
260
+ // pages: [{ url, path?, title, description?, text? | sections? | html? }] → { llms, llmsFull }.
261
+ function build(pages, options) {
262
+ const o = options || {};
263
+ const list = pages.map((p) => {
264
+ if (p.html && !p.text && !p.sections) {
265
+ const info = readHtml(p.html, { content: o.content, exclude: o.remove });
266
+ return Object.assign({}, p, { title: p.title || info.title, description: p.description || info.description, sections: info.sections, text: info.text });
267
+ }
268
+ return p;
269
+ }).filter((p) => p && (p.text || (p.sections && p.sections.length)));
270
+ const home = list.find((p) => (p.path || "") === "/") || list[0] || {};
271
+ const title = oneLine(o.title || home.siteName || (home.docTitle || home.title || "").split(/\s+[|·–—-]\s+/).pop() || "Site");
272
+ const description = oneLine(o.description || home.description || "");
273
+ const link = (p) => p.url || (o.site ? joinUrl(o.site, p.path || "/") : p.path || "");
274
+
275
+ const index = ["# " + title, ""];
276
+ if (description) index.push("> " + description, "");
277
+ index.push("## " + (o.section || "Pages"), "");
278
+ for (const p of list) {
279
+ const d = oneLine(p.description);
280
+ index.push("- [" + oneLine(p.title).replace(/[[\]]/g, "") + "](" + link(p) + ")" + (d ? ": " + d : ""));
281
+ }
282
+ if (o.full !== false) index.push("", "## Optional", "", "- [Full text of every page](" + (o.site ? joinUrl(o.site, "/llms-full.txt") : "/llms-full.txt") + ")");
283
+ index.push("", MARK, "");
284
+
285
+ const full = [];
286
+ for (const p of list) {
287
+ const url = link(p);
288
+ const abs = /^https?:\/\//i.test(url);
289
+ full.push("# " + oneLine(p.title), "");
290
+ if (abs) full.push("Source: " + url, "");
291
+ const sections = p.sections || [{ heading: "", id: "", text: p.text }];
292
+ for (const s of sections) {
293
+ if (s.heading) {
294
+ full.push("## " + oneLine(s.heading), "");
295
+ if (abs && s.id) full.push("Source: " + url.replace(/#.*$/, "") + "#" + encodeURIComponent(s.id), "");
296
+ }
297
+ if (s.text) full.push(s.text, "");
298
+ }
299
+ }
300
+ full.push(MARK, "");
301
+ return { title, description, pages: list, llms: index.join("\n"), llmsFull: full.join("\n").replace(/\n{3,}/g, "\n\n") };
302
+ }
303
+
304
+ // A file we did not write (another plugin's llms.txt, or one written by hand) is left alone.
305
+ function writeOwn(file, text, force) {
306
+ if (!force && fs.existsSync(file) && !fs.readFileSync(file, "utf8").includes(MARK)) return false;
307
+ fs.mkdirSync(path.dirname(file), { recursive: true });
308
+ fs.writeFileSync(file, text);
309
+ return true;
310
+ }
311
+
312
+ // Scan a built site and write llms.txt and llms-full.txt into it.
313
+ function generate(dir, options) {
314
+ const o = options || {};
315
+ const pages = o.pages || scan(dir, o);
316
+ const result = build(pages, o);
317
+ const out = path.resolve(o.out || dir);
318
+ const written = [], skipped = [];
319
+ const put = (name, text) => (o.write === false ? null : (writeOwn(path.join(out, name), text, o.force) ? written : skipped).push(name));
320
+ put("llms.txt", result.llms);
321
+ if (o.full !== false) put("llms-full.txt", result.llmsFull);
322
+ return Object.assign(result, { written, skipped });
323
+ }
324
+
325
+ const attr = (v) => String(v).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
326
+
327
+ // The Ask AI script tag. Options become data-* attributes; `ref` names the plugin that added it.
328
+ function scriptTag(options) {
329
+ const o = options || {};
330
+ const names = ["key", "ref", "label", "placeholder", "position", "button", "color", "theme", "lang", "rules", "index"];
331
+ let tag = '<script async src="' + attr(o.src || SCRIPT) + '"';
332
+ for (const n of names) if (o[n] !== undefined && o[n] !== null && o[n] !== "") tag += " data-" + n + '="' + attr(o[n]) + '"';
333
+ return tag + "></script>";
334
+ }
335
+
336
+ // Add the Ask AI script tag before </body> of every page in a built site. Running it twice changes nothing.
337
+ function inject(dir, options) {
338
+ const root = path.resolve(dir);
339
+ const o = options || {};
340
+ const excludes = [].concat(o.exclude || []).map(globToRegExp);
341
+ const tag = scriptTag(o);
342
+ const changed = [];
343
+ for (const rel of walkHtml(root)) {
344
+ if (excludes.some((re) => re.test(rel))) continue;
345
+ const file = path.join(root, rel);
346
+ const html = fs.readFileSync(file, "utf8");
347
+ if (/<script[^>]+\/ask-docs\.js/i.test(html)) continue;
348
+ const at = html.search(/<\/body\s*>(?![\s\S]*<\/body\s*>)/i);
349
+ if (at === -1) continue;
350
+ fs.writeFileSync(file, html.slice(0, at) + tag + "\n" + html.slice(at));
351
+ changed.push(rel);
352
+ }
353
+ return changed;
354
+ }
355
+
356
+ module.exports = { generate, scan, build, readHtml, inject, scriptTag, globToRegExp, MARK, SCRIPT };
package/index.d.ts ADDED
@@ -0,0 +1,129 @@
1
+ export interface Section {
2
+ /** Text of the h2 that opens the section. Empty for the text before the first h2. */
3
+ heading: string;
4
+ /** The heading's id attribute, used for #anchor links. */
5
+ id: string;
6
+ /** Markdown text of the section. */
7
+ text: string;
8
+ }
9
+
10
+ export interface PageInfo {
11
+ title: string;
12
+ docTitle: string;
13
+ siteName: string;
14
+ description: string;
15
+ canonical: string;
16
+ noindex: boolean;
17
+ redirect: boolean;
18
+ lang: string;
19
+ sections: Section[];
20
+ text: string;
21
+ }
22
+
23
+ export interface Page {
24
+ /** Absolute address of the page. */
25
+ url?: string;
26
+ /** Path from the site root, e.g. "/guide/". */
27
+ path?: string;
28
+ /** File path relative to the scanned folder (set by scan). */
29
+ file?: string;
30
+ title: string;
31
+ description?: string;
32
+ /** Markdown text. Give text, sections or html. */
33
+ text?: string;
34
+ sections?: Section[];
35
+ /** Raw HTML of the page; build() reads it with readHtml(). */
36
+ html?: string;
37
+ }
38
+
39
+ export interface ReadOptions {
40
+ /** CSS selector (or list) of the main content. Default: main, [role=main], article, #content, .content, body. */
41
+ content?: string | string[];
42
+ /** Extra CSS selectors to drop from the content. */
43
+ exclude?: string | string[];
44
+ }
45
+
46
+ export interface Options {
47
+ /** Public address of the site, e.g. "https://example.com". Makes every link absolute. */
48
+ site?: string;
49
+ /** Site title. Default: taken from the home page. */
50
+ title?: string;
51
+ /** One-line summary under the title. Default: the home page meta description. */
52
+ description?: string;
53
+ /** Heading of the page list in llms.txt. Default "Pages". */
54
+ section?: string;
55
+ /** CSS selector (or list) of the main content. */
56
+ content?: string | string[];
57
+ /** Extra CSS selectors to drop from every page. */
58
+ remove?: string | string[];
59
+ /** Globs of files to skip, relative to the folder, e.g. "tags/**". */
60
+ exclude?: string | string[];
61
+ /** Link /about instead of /about.html. */
62
+ cleanUrls?: boolean;
63
+ /** Pages with less text than this are skipped. Default 40 characters. */
64
+ minLength?: number;
65
+ /** false writes llms.txt only. */
66
+ full?: boolean;
67
+ /** Overwrite an llms.txt that this tool did not write. */
68
+ force?: boolean;
69
+ /** Write to this folder instead of the scanned one. */
70
+ out?: string;
71
+ /** false returns the text without writing files. */
72
+ write?: boolean;
73
+ /** Use these pages instead of scanning the folder. */
74
+ pages?: Page[];
75
+ }
76
+
77
+ export interface Result {
78
+ title: string;
79
+ description: string;
80
+ pages: Page[];
81
+ /** Text of llms.txt. */
82
+ llms: string;
83
+ /** Text of llms-full.txt. */
84
+ llmsFull: string;
85
+ }
86
+
87
+ export interface GenerateResult extends Result {
88
+ written: string[];
89
+ skipped: string[];
90
+ }
91
+
92
+ export interface ScriptOptions {
93
+ /** OneTag AI site key. Public by design. */
94
+ key?: string;
95
+ /** Short name of the tool that added the tag. */
96
+ ref?: string;
97
+ label?: string;
98
+ placeholder?: string;
99
+ position?: "auto" | "float" | "left" | "none";
100
+ /** CSS selector of your own button that opens the box. */
101
+ button?: string;
102
+ /** Accent color as hex without "#". */
103
+ color?: string;
104
+ theme?: "light" | "dark";
105
+ lang?: string;
106
+ rules?: string;
107
+ index?: string;
108
+ src?: string;
109
+ /** Globs of files to leave alone (inject only). */
110
+ exclude?: string | string[];
111
+ }
112
+
113
+ /** Scan a built site and write llms.txt and llms-full.txt into it. */
114
+ export function generate(dir: string, options?: Options): GenerateResult;
115
+ /** Read every page of a built site. */
116
+ export function scan(dir: string, options?: Options): Page[];
117
+ /** Turn a list of pages into the text of both files. */
118
+ export function build(pages: Page[], options?: Options): Result;
119
+ /** Read one HTML document: title, description and main content as Markdown. */
120
+ export function readHtml(html: string, options?: ReadOptions): PageInfo;
121
+ /** Add the Ask AI script tag before </body> of every page. Returns the files changed. */
122
+ export function inject(dir: string, options?: ScriptOptions): string[];
123
+ /** The Ask AI script tag as a string. */
124
+ export function scriptTag(options?: ScriptOptions): string;
125
+ export function globToRegExp(glob: string): RegExp;
126
+ export const MARK: string;
127
+ export const SCRIPT: string;
128
+ declare const core: { generate: typeof generate; scan: typeof scan; build: typeof build; readHtml: typeof readHtml; inject: typeof inject; scriptTag: typeof scriptTag; globToRegExp: typeof globToRegExp; MARK: string; SCRIPT: string };
129
+ export default core;
package/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ import core from "./index.cjs";
2
+ export const { generate, scan, build, readHtml, inject, scriptTag, globToRegExp, MARK, SCRIPT } = core;
3
+ export default core;
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "static-llms-txt",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Write llms.txt and llms-full.txt for any static site from the HTML it already built. Works after Eleventy, Hugo, Jekyll, Astro, Gatsby, Docusaurus or plain HTML. No API key.",
5
+ "main": "./index.cjs",
6
+ "module": "./index.mjs",
7
+ "types": "./index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./index.mjs",
12
+ "require": "./index.cjs"
13
+ }
14
+ },
15
+ "bin": {
16
+ "static-llms-txt": "./cli.cjs"
17
+ },
18
+ "files": ["index.cjs", "index.mjs", "index.d.ts", "cli.cjs", "README.md", "LICENSE"],
19
+ "scripts": {
20
+ "test": "node --test test/*.test.mjs",
21
+ "prepublishOnly": "npm test"
22
+ },
23
+ "keywords": ["llms.txt", "llms-txt", "llms-full.txt", "llmstxt", "static-site", "html-to-markdown", "ssg", "eleventy", "hugo", "jekyll", "ai", "seo", "geo", "cli"],
24
+ "license": "MIT",
25
+ "author": "OneTag AI (https://onetagai.com)",
26
+ "homepage": "https://github.com/fengyiqicoder/onetagai/tree/main/packages/static-llms-txt#readme",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/fengyiqicoder/onetagai.git",
30
+ "directory": "packages/static-llms-txt"
31
+ },
32
+ "bugs": "https://github.com/fengyiqicoder/onetagai/issues",
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "dependencies": {
37
+ "node-html-parser": "^7.0.1"
38
+ }
39
+ }