human-machine-swapper 1.1.0 → 1.2.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/README.md +5 -0
- package/bin/cli.mjs +5 -2
- package/package.json +2 -1
- package/src/init/index.mjs +11 -0
- package/src/init/llms.mjs +10 -2
- package/src/init/pages.mjs +185 -0
package/README.md
CHANGED
|
@@ -22,6 +22,11 @@ and one custom element.
|
|
|
22
22
|
npx human-machine-swapper init
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
> Only here for the `llms.txt` and not the pill? [`npm create llms-txt`](https://www.npmjs.com/package/create-llms-txt)
|
|
26
|
+
> writes the file from your routes and offers the pill at the end. Same code, different
|
|
27
|
+
> front door.
|
|
28
|
+
|
|
29
|
+
|
|
25
30
|
Detects your framework, writes a starter `llms.txt` if you have none, declares it, loads the
|
|
26
31
|
component **pinned to a version with an integrity hash**, mounts the element, and adds the
|
|
27
32
|
`:not(:defined)` guard below. Idempotent, never overwrites an existing `llms.txt`, and
|
package/bin/cli.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
|
|
7
7
|
import { detect, readSiteFacts } from "../src/init/detect.mjs";
|
|
8
8
|
import { buildLlmsTxt, countTodos } from "../src/init/llms.mjs";
|
|
9
|
+
import { findPages } from "../src/init/pages.mjs";
|
|
9
10
|
import { apply, buildScriptTag, isJsxLayout, ELEMENT_TAG, LINK_TAG, CSS_GUARD } from "../src/init/apply.mjs";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -117,7 +118,8 @@ Then make sure the site serves an llms.txt. Full docs: ${DOCS}`);
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
const facts = readSiteFacts(args.dir);
|
|
120
|
-
const
|
|
121
|
+
const pages = findPages(args.dir, framework);
|
|
122
|
+
const llmsTxt = buildLlmsTxt(facts, { framework: framework.name, pages });
|
|
121
123
|
const scriptTag = buildScriptTag({
|
|
122
124
|
version: PKG.version,
|
|
123
125
|
integrity: integrityOfSelf(),
|
|
@@ -125,7 +127,8 @@ Then make sure the site serves an llms.txt. Full docs: ${DOCS}`);
|
|
|
125
127
|
jsx: isJsxLayout(framework.layout),
|
|
126
128
|
});
|
|
127
129
|
|
|
128
|
-
|
|
130
|
+
const pageNote = pages.length ? ` ${C.dim}${pages.length} page${pages.length === 1 ? "" : "s"} read${C.x}` : "";
|
|
131
|
+
console.log(`\n${C.b}${framework.name}${C.x}${pageNote}${args.dryRun ? ` ${C.y}(dry run, nothing written)${C.x}` : ""}\n`);
|
|
129
132
|
|
|
130
133
|
const { actions, wrote } = apply({ dir: args.dir, framework, llmsTxt, scriptTag, dryRun: args.dryRun });
|
|
131
134
|
report(actions);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "human-machine-swapper",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "A web component that adds a Human / Machine pill to any website. Machine shows the site's llms.txt, the plain-text reading written for language models, rendered in place. One file, about 6 KB gzipped, no dependencies and no build step.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llms.txt",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"jsdelivr": "src/human-machine-swapper.js",
|
|
35
35
|
"exports": {
|
|
36
36
|
".": "./src/human-machine-swapper.js",
|
|
37
|
+
"./init": "./src/init/index.mjs",
|
|
37
38
|
"./package.json": "./package.json"
|
|
38
39
|
},
|
|
39
40
|
"files": [
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The init toolkit, exported so a second front door can reuse it rather than fork it.
|
|
3
|
+
*
|
|
4
|
+
* `create-llms-txt` is the same machinery pointed at a different question: people search
|
|
5
|
+
* for "how do I add llms.txt", not for "human machine toggle". One codebase, two entry
|
|
6
|
+
* points, so the two can never drift into disagreeing about how to install this.
|
|
7
|
+
*/
|
|
8
|
+
export { detect, readSiteFacts } from "./detect.mjs";
|
|
9
|
+
export { findPages } from "./pages.mjs";
|
|
10
|
+
export { buildLlmsTxt, countTodos } from "./llms.mjs";
|
|
11
|
+
export { apply, buildScriptTag, isJsxLayout, LINK_TAG, ELEMENT_TAG, CSS_GUARD } from "./apply.mjs";
|
package/src/init/llms.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
const TODO = "TODO";
|
|
14
14
|
|
|
15
|
-
export function buildLlmsTxt(facts = {}, { framework } = {}) {
|
|
15
|
+
export function buildLlmsTxt(facts = {}, { framework, pages } = {}) {
|
|
16
16
|
const name = facts.name || "This site";
|
|
17
17
|
const summary = facts.description || `${TODO}: one sentence on what this site is.`;
|
|
18
18
|
|
|
@@ -30,7 +30,15 @@ export function buildLlmsTxt(facts = {}, { framework } = {}) {
|
|
|
30
30
|
}
|
|
31
31
|
lines.push("");
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// The pages are the point. A file built from a README says what the project calls
|
|
34
|
+
// itself; one built from the routes says what is ON the site, which is what a model
|
|
35
|
+
// needs to answer "where do I find X".
|
|
36
|
+
if (pages && pages.length) {
|
|
37
|
+
lines.push("## Pages");
|
|
38
|
+
lines.push("");
|
|
39
|
+
for (const p of pages) lines.push(`- [${p.title}](${p.route})`);
|
|
40
|
+
lines.push("");
|
|
41
|
+
} else if (facts.headings && facts.headings.length) {
|
|
34
42
|
lines.push("## Sections");
|
|
35
43
|
lines.push("");
|
|
36
44
|
for (const h of facts.headings) lines.push(`- ${h}`);
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, extname, join, relative, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Finds the pages a site actually has, so the generated llms.txt can list them.
|
|
6
|
+
*
|
|
7
|
+
* This is what makes a generated file worth keeping. An llms.txt built from a README says
|
|
8
|
+
* what the project calls itself; one built from the routes says what is *on the site*,
|
|
9
|
+
* which is the thing a model needs in order to answer "where do I find X".
|
|
10
|
+
*
|
|
11
|
+
* Read-only and best-effort throughout. A site with an unusual layout gets fewer pages
|
|
12
|
+
* listed, never a crash and never an invented route.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const CODE = new Set([".js", ".jsx", ".ts", ".tsx", ".astro", ".vue", ".svelte"]);
|
|
16
|
+
const PROSE = new Set([".md", ".mdx", ".markdown"]);
|
|
17
|
+
const SKIP_DIRS = new Set([
|
|
18
|
+
"node_modules", ".git", ".next", ".nuxt", ".svelte-kit", "dist", "build", "out",
|
|
19
|
+
"coverage", ".vercel", ".astro", "public", "static", "assets",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/** Hard ceiling. An llms.txt listing 400 routes is noise, not a machine reading. */
|
|
23
|
+
const MAX_PAGES = 60;
|
|
24
|
+
|
|
25
|
+
function walk(dir, root = dir, out = [], depth = 0) {
|
|
26
|
+
if (depth > 8) return out;
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
30
|
+
} catch {
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
for (const e of entries) {
|
|
34
|
+
if (e.name.startsWith(".") && e.name !== ".") continue;
|
|
35
|
+
const abs = join(dir, e.name);
|
|
36
|
+
if (e.isDirectory()) {
|
|
37
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
38
|
+
walk(abs, root, out, depth + 1);
|
|
39
|
+
} else {
|
|
40
|
+
out.push(relative(root, abs));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `app/blog/[slug]/page.tsx` -> `/blog/[slug]`, `app/page.tsx` -> `/` */
|
|
47
|
+
function routeFromNextApp(rel) {
|
|
48
|
+
const parts = rel.split(sep);
|
|
49
|
+
parts.pop(); // page.tsx
|
|
50
|
+
const segs = parts
|
|
51
|
+
.slice(1) // drop app/ or src/
|
|
52
|
+
.filter((p) => p !== "app")
|
|
53
|
+
// (marketing) and @slots are organisational, not URL segments
|
|
54
|
+
.filter((p) => !(p.startsWith("(") && p.endsWith(")")) && !p.startsWith("@"));
|
|
55
|
+
return "/" + segs.join("/");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function routeFromFile(rel, baseDir) {
|
|
59
|
+
let p = rel.replace(new RegExp(`^${baseDir}\\${sep}?`), "");
|
|
60
|
+
p = p.replace(new RegExp(`\\${extname(p)}$`), "");
|
|
61
|
+
p = p.replace(/(^|\/)index$/, "");
|
|
62
|
+
p = p.replace(/\+page$/, "").replace(/\/$/, "");
|
|
63
|
+
return "/" + p.split(sep).filter(Boolean).join("/");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A page's title, from whatever the file happens to declare. Checked in order of how
|
|
68
|
+
* likely each is to be the real human-facing title.
|
|
69
|
+
*/
|
|
70
|
+
function titleOf(abs, route) {
|
|
71
|
+
let text = "";
|
|
72
|
+
try {
|
|
73
|
+
text = readFileSync(abs, "utf8").slice(0, 8000);
|
|
74
|
+
} catch {
|
|
75
|
+
return titleiseRoute(route);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
79
|
+
if (frontmatter) {
|
|
80
|
+
const t = frontmatter[1].match(/^title:\s*["']?(.+?)["']?\s*$/m);
|
|
81
|
+
if (t) return clean(t[1]);
|
|
82
|
+
}
|
|
83
|
+
// Next metadata: `title: "..."` inside an exported metadata object.
|
|
84
|
+
const meta = text.match(/export\s+const\s+metadata[\s\S]{0,400}?title:\s*["'`](.+?)["'`]/);
|
|
85
|
+
if (meta) return clean(meta[1]);
|
|
86
|
+
|
|
87
|
+
const h1 = text.match(/<h1[^>]*>([^<]{2,120})<\/h1>/i);
|
|
88
|
+
if (h1) return clean(h1[1]);
|
|
89
|
+
|
|
90
|
+
const md = text.match(/^#\s+(.{2,120})$/m);
|
|
91
|
+
if (md) return clean(md[1]);
|
|
92
|
+
|
|
93
|
+
return titleiseRoute(route);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const clean = (s) => s.replace(/\s+/g, " ").trim();
|
|
97
|
+
|
|
98
|
+
function titleiseRoute(route) {
|
|
99
|
+
const last = route.split("/").filter(Boolean).pop();
|
|
100
|
+
if (!last) return "Home";
|
|
101
|
+
return last
|
|
102
|
+
.replace(/[[\]]/g, "")
|
|
103
|
+
.split(/[-_]/)
|
|
104
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
105
|
+
.join(" ");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Dynamic routes are a template, not a page; listing `/blog/[slug]` helps nobody. */
|
|
109
|
+
const isDynamic = (route) => /\[.+\]|:\w+/.test(route);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @returns {{route:string,title:string}[]} deduped, sorted, dynamic routes dropped
|
|
113
|
+
*/
|
|
114
|
+
export function findPages(dir, framework) {
|
|
115
|
+
if (!framework) return [];
|
|
116
|
+
const files = walk(dir);
|
|
117
|
+
const found = [];
|
|
118
|
+
|
|
119
|
+
const add = (route, abs) => {
|
|
120
|
+
if (!route || isDynamic(route)) return;
|
|
121
|
+
found.push({ route: route === "" ? "/" : route, abs });
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
for (const rel of files) {
|
|
125
|
+
const ext = extname(rel);
|
|
126
|
+
const abs = join(dir, rel);
|
|
127
|
+
|
|
128
|
+
switch (framework.id) {
|
|
129
|
+
case "next-app":
|
|
130
|
+
if (/(^|\/)page\.(js|jsx|ts|tsx)$/.test(rel.split(sep).join("/")) && /^(src\/)?app\//.test(rel.split(sep).join("/"))) {
|
|
131
|
+
add(routeFromNextApp(rel), abs);
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
case "next-pages":
|
|
135
|
+
if (CODE.has(ext) && /^(src\/)?pages\//.test(rel.split(sep).join("/"))) {
|
|
136
|
+
const name = basename(rel);
|
|
137
|
+
if (name.startsWith("_") || rel.split(sep).join("/").includes("pages/api/")) break;
|
|
138
|
+
add(routeFromFile(rel.replace(/^src\//, ""), "pages"), abs);
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
case "astro":
|
|
142
|
+
if ((CODE.has(ext) || PROSE.has(ext)) && rel.split(sep).join("/").startsWith("src/pages/")) {
|
|
143
|
+
add(routeFromFile(rel.replace(/^src\//, ""), "pages"), abs);
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
case "sveltekit":
|
|
147
|
+
if (/\+page\.(svelte|md)$/.test(rel) && rel.split(sep).join("/").startsWith("src/routes/")) {
|
|
148
|
+
add(routeFromFile(rel.replace(/^src\//, ""), "routes"), abs);
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
case "nuxt":
|
|
152
|
+
if (ext === ".vue" && rel.split(sep).join("/").startsWith("pages/")) {
|
|
153
|
+
add(routeFromFile(rel, "pages"), abs);
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
case "html":
|
|
157
|
+
if (ext === ".html") add(routeFromFile(rel, ""), abs);
|
|
158
|
+
break;
|
|
159
|
+
default:
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Markdown content collections are pages too on most of these stacks.
|
|
165
|
+
if (framework.id !== "html") {
|
|
166
|
+
for (const rel of files) {
|
|
167
|
+
const unix = rel.split(sep).join("/");
|
|
168
|
+
if (!PROSE.has(extname(rel))) continue;
|
|
169
|
+
if (!/^(src\/)?content\//.test(unix)) continue;
|
|
170
|
+
add("/" + unix.replace(/^(src\/)?content\//, "").replace(/\.[^.]+$/, ""), join(dir, rel));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const seen = new Set();
|
|
175
|
+
const pages = [];
|
|
176
|
+
for (const f of found) {
|
|
177
|
+
if (seen.has(f.route)) continue;
|
|
178
|
+
seen.add(f.route);
|
|
179
|
+
pages.push({ route: f.route, title: titleOf(f.abs, f.route) });
|
|
180
|
+
if (pages.length >= MAX_PAGES) break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
pages.sort((a, b) => (a.route === "/" ? -1 : b.route === "/" ? 1 : a.route.localeCompare(b.route)));
|
|
184
|
+
return pages;
|
|
185
|
+
}
|