human-machine-swapper 1.1.0 → 1.2.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/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 llmsTxt = buildLlmsTxt(facts, { framework: framework.name });
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
- console.log(`\n${C.b}${framework.name}${C.x}${args.dryRun ? ` ${C.y}(dry run, nothing written)${C.x}` : ""}\n`);
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.1.0",
3
+ "version": "1.2.1",
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": [
@@ -73,18 +73,27 @@ function insertBefore(source, marker, snippet) {
73
73
  const at = source.indexOf(marker);
74
74
  if (at === -1) return null;
75
75
  const lineStart = source.lastIndexOf("\n", at) + 1;
76
- // The marker is a CLOSING tag, so its own indentation is the parent's. Children sit one
77
- // level in, and every line of a multi-line snippet gets the same treatment or the
78
- // continuation lines land wherever they happened to be authored.
79
- const indent = source.slice(lineStart, at).match(/^\s*/)[0] + " ";
80
- // Keep the snippet's OWN relative indentation: a multi-line tag authors its attributes
81
- // one level in and its closing bracket flush, and flattening that puts `></script>`
76
+ const before = source.slice(lineStart, at);
77
+ const indent = before.match(/^\s*/)[0];
78
+
79
+ // Indent every line of the snippet one level in from the closing tag, keeping the
80
+ // snippet's own relative indentation so a multi-line tag's `></script>` does not end up
82
81
  // deeper than the tag it closes.
83
82
  const body = snippet
84
83
  .split("\n")
85
- .map((l) => (l.trim() ? indent + l : l))
84
+ .map((l) => (l.trim() ? indent + " " + l : l))
86
85
  .join("\n");
87
- return source.slice(0, lineStart) + body + "\n" + source.slice(lineStart);
86
+
87
+ // Two shapes, and getting this wrong writes broken HTML into somebody's repo.
88
+ //
89
+ // When the closing tag starts its own line, insert a line above it. When it does NOT -
90
+ // `<head><meta /><title>x</title></head>` all on one line - inserting "before the line"
91
+ // puts the tags OUTSIDE the element entirely, above <head>. That is what happened to the
92
+ // first Astro site this ran on. Split the line instead and keep the content inside.
93
+ if (before.trim() === "") {
94
+ return source.slice(0, lineStart) + body + "\n" + source.slice(lineStart);
95
+ }
96
+ return source.slice(0, at) + "\n" + body + "\n" + indent + source.slice(at);
88
97
  }
89
98
 
90
99
  /** Already installed? Then there is nothing to do and we say so instead of doubling up. */
@@ -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
- if (facts.headings && facts.headings.length) {
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
+ }