human-machine-swapper 1.0.2 → 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/AGENTS.md +23 -3
- package/README.md +26 -1
- package/bin/cli.mjs +164 -0
- package/llms.txt +17 -3
- package/package.json +6 -1
- package/src/init/apply.mjs +213 -0
- package/src/init/detect.mjs +208 -0
- package/src/init/index.mjs +11 -0
- package/src/init/llms.mjs +102 -0
- package/src/init/pages.mjs +185 -0
package/AGENTS.md
CHANGED
|
@@ -7,7 +7,27 @@ Instructions for coding agents adding this component to a site.
|
|
|
7
7
|
A dependency-free web component that adds a Human / Machine pill. Machine shows the site's
|
|
8
8
|
`llms.txt` rendered in place. One script tag, one custom element, no build step.
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Do this first
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx human-machine-swapper init
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
It detects the framework, writes a starter `llms.txt` if the site has none, declares it,
|
|
17
|
+
loads the component pinned to a version with an integrity hash, mounts the element, and adds
|
|
18
|
+
the `:not(:defined)` guard. It is idempotent, so running it twice is safe, and it never
|
|
19
|
+
overwrites an existing `llms.txt`.
|
|
20
|
+
|
|
21
|
+
Add `--dry-run` to see the changes without writing them.
|
|
22
|
+
|
|
23
|
+
**Read the output.** It reports anything it could not work out rather than guessing, and it
|
|
24
|
+
tells you how many `TODO`s are left in the generated `llms.txt`. Exit codes: `0` installed,
|
|
25
|
+
`1` could not identify the project, `2` installed but something needs a human.
|
|
26
|
+
|
|
27
|
+
If the command cannot identify the project, or you are editing a site by hand, the install
|
|
28
|
+
is these three things plus one CSS rule.
|
|
29
|
+
|
|
30
|
+
## By hand, if you have to
|
|
11
31
|
|
|
12
32
|
1. **Load the script.**
|
|
13
33
|
|
|
@@ -30,8 +50,8 @@ A dependency-free web component that adds a Human / Machine pill. Machine shows
|
|
|
30
50
|
human-machine-swapper:not(:defined) { display: none; }
|
|
31
51
|
```
|
|
32
52
|
|
|
33
|
-
Without it, the element is laid out in flow at ~50px tall until the script loads and
|
|
34
|
-
the rest of the page down.
|
|
53
|
+
Without it, the element is laid out in flow at ~50px tall until the script loads and
|
|
54
|
+
pushes the rest of the page down.
|
|
35
55
|
|
|
36
56
|
## The site must have an llms.txt
|
|
37
57
|
|
package/README.md
CHANGED
|
@@ -16,6 +16,25 @@ and one custom element.
|
|
|
16
16
|
|
|
17
17
|
## Install
|
|
18
18
|
|
|
19
|
+
### One command
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx human-machine-swapper init
|
|
23
|
+
```
|
|
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
|
+
|
|
30
|
+
Detects your framework, writes a starter `llms.txt` if you have none, declares it, loads the
|
|
31
|
+
component **pinned to a version with an integrity hash**, mounts the element, and adds the
|
|
32
|
+
`:not(:defined)` guard below. Idempotent, never overwrites an existing `llms.txt`, and
|
|
33
|
+
`--dry-run` shows the changes without making them.
|
|
34
|
+
|
|
35
|
+
Knows Next (app and pages router), Astro, SvelteKit, Nuxt, WordPress themes and plain HTML.
|
|
36
|
+
If it cannot identify your project it changes nothing and prints the manual steps.
|
|
37
|
+
|
|
19
38
|
### Script tag
|
|
20
39
|
|
|
21
40
|
```html
|
|
@@ -38,7 +57,8 @@ import "human-machine-swapper";
|
|
|
38
57
|
|
|
39
58
|
## Required setup
|
|
40
59
|
|
|
41
|
-
Three things.
|
|
60
|
+
Three things. `init` does all of them; this is what it is doing, and what to do by hand.
|
|
61
|
+
Skip any one and the pill will not appear, or will appear broken.
|
|
42
62
|
|
|
43
63
|
### 1. Publish an `llms.txt`
|
|
44
64
|
|
|
@@ -288,6 +308,11 @@ of its own beyond reading your `llms.txt`.
|
|
|
288
308
|
|
|
289
309
|
---
|
|
290
310
|
|
|
311
|
+
## Contributing
|
|
312
|
+
|
|
313
|
+
Releases are cut by pushing a tag; see [RELEASING.md](RELEASING.md). There is no npm token —
|
|
314
|
+
publishing uses trusted publishing, and every release carries a provenance attestation.
|
|
315
|
+
|
|
291
316
|
## Licence
|
|
292
317
|
|
|
293
318
|
Apache-2.0.
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { detect, readSiteFacts } from "../src/init/detect.mjs";
|
|
8
|
+
import { buildLlmsTxt, countTodos } from "../src/init/llms.mjs";
|
|
9
|
+
import { findPages } from "../src/init/pages.mjs";
|
|
10
|
+
import { apply, buildScriptTag, isJsxLayout, ELEMENT_TAG, LINK_TAG, CSS_GUARD } from "../src/init/apply.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `npx human-machine-swapper init`
|
|
14
|
+
*
|
|
15
|
+
* Non-interactive on purpose. The most common caller is a coding agent that was told "add a
|
|
16
|
+
* human/machine toggle to this site", and a prompt it cannot answer is a hang. Everything
|
|
17
|
+
* is decided from what is on disk; anything undecidable is reported as manual rather than
|
|
18
|
+
* guessed at.
|
|
19
|
+
*
|
|
20
|
+
* Exit codes are meaningful, because an agent reads them:
|
|
21
|
+
* 0 installed, or already installed
|
|
22
|
+
* 1 could not tell what this project is
|
|
23
|
+
* 2 installed, but something needs a human
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const PKG = JSON.parse(readFileSync(join(HERE, "../package.json"), "utf8"));
|
|
28
|
+
const DOCS = "https://advancelabs.dev/human-machine-swapper";
|
|
29
|
+
|
|
30
|
+
const C = process.stdout.isTTY && !process.env.NO_COLOR
|
|
31
|
+
? { dim: "\x1b[2m", b: "\x1b[1m", g: "\x1b[32m", y: "\x1b[33m", r: "\x1b[31m", x: "\x1b[0m" }
|
|
32
|
+
: { dim: "", b: "", g: "", y: "", r: "", x: "" };
|
|
33
|
+
|
|
34
|
+
function usage() {
|
|
35
|
+
console.log(`${C.b}human-machine-swapper${C.x} ${PKG.version}
|
|
36
|
+
|
|
37
|
+
npx human-machine-swapper init [options]
|
|
38
|
+
|
|
39
|
+
Adds the Human/Machine pill to this project: writes a starter llms.txt, declares it,
|
|
40
|
+
loads the component, mounts it, and adds the :not(:defined) guard that stops the
|
|
41
|
+
element being laid out in flow before the script arrives.
|
|
42
|
+
|
|
43
|
+
Options
|
|
44
|
+
--dry-run Show what would change, write nothing
|
|
45
|
+
--latest Use the floating CDN url instead of pinning this version with an
|
|
46
|
+
integrity hash. Auto-updates, but cannot be verified
|
|
47
|
+
--dir <path> Project root (default: cwd)
|
|
48
|
+
--help This
|
|
49
|
+
|
|
50
|
+
Docs: ${DOCS}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseArgs(argv) {
|
|
54
|
+
// The first NON-flag is the command. Taking argv[0] blindly made `--help` an unknown
|
|
55
|
+
// command, which is the first thing anybody types.
|
|
56
|
+
const args = { cmd: null, dryRun: false, pin: true, dir: process.cwd() };
|
|
57
|
+
for (let i = 0; i < argv.length; i++) {
|
|
58
|
+
const a = argv[i];
|
|
59
|
+
if (a === "--dry-run" || a === "-n") args.dryRun = true;
|
|
60
|
+
else if (a === "--latest") args.pin = false;
|
|
61
|
+
else if (a === "--dir") args.dir = argv[++i];
|
|
62
|
+
else if (a === "--help" || a === "-h") args.help = true;
|
|
63
|
+
else if (!a.startsWith("-") && !args.cmd) args.cmd = a;
|
|
64
|
+
}
|
|
65
|
+
return args;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** sha384 of the file this very package will have published. Verifiable, not asserted. */
|
|
69
|
+
function integrityOfSelf() {
|
|
70
|
+
try {
|
|
71
|
+
const bytes = readFileSync(join(HERE, "../src/human-machine-swapper.js"));
|
|
72
|
+
return "sha384-" + createHash("sha384").update(bytes).digest("base64");
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function report(actions) {
|
|
79
|
+
const mark = { done: `${C.g}+${C.x}`, skipped: `${C.dim}=${C.x}`, manual: `${C.y}!${C.x}` };
|
|
80
|
+
for (const a of actions) {
|
|
81
|
+
const where = a.file ? ` ${C.dim}${a.file}${C.x}` : "";
|
|
82
|
+
const note = a.note ? ` ${C.dim}(${a.note})${C.x}` : "";
|
|
83
|
+
console.log(` ${mark[a.status]} ${a.what}${where}${note}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function main() {
|
|
88
|
+
const args = parseArgs(process.argv.slice(2));
|
|
89
|
+
|
|
90
|
+
if (args.help || !args.cmd) {
|
|
91
|
+
usage();
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
if (args.cmd !== "init") {
|
|
95
|
+
console.error(`Unknown command "${args.cmd}". Try: npx human-machine-swapper init`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const framework = detect(args.dir);
|
|
100
|
+
if (!framework) {
|
|
101
|
+
console.error(`${C.r}Could not tell what kind of project this is.${C.x}
|
|
102
|
+
|
|
103
|
+
Looked for a Next, Astro, SvelteKit, Nuxt or WordPress project, or an index.html.
|
|
104
|
+
Nothing matched, so nothing was changed.
|
|
105
|
+
|
|
106
|
+
Install it by hand, which is three things:
|
|
107
|
+
|
|
108
|
+
1. ${LINK_TAG}
|
|
109
|
+
2. ${buildScriptTag({ pin: false })}
|
|
110
|
+
3. ${ELEMENT_TAG}
|
|
111
|
+
|
|
112
|
+
and this CSS, which is not optional:
|
|
113
|
+
|
|
114
|
+
${CSS_GUARD.split("\n").map((l) => " " + l).join("\n")}
|
|
115
|
+
|
|
116
|
+
Then make sure the site serves an llms.txt. Full docs: ${DOCS}`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const facts = readSiteFacts(args.dir);
|
|
121
|
+
const pages = findPages(args.dir, framework);
|
|
122
|
+
const llmsTxt = buildLlmsTxt(facts, { framework: framework.name, pages });
|
|
123
|
+
const scriptTag = buildScriptTag({
|
|
124
|
+
version: PKG.version,
|
|
125
|
+
integrity: integrityOfSelf(),
|
|
126
|
+
pin: args.pin,
|
|
127
|
+
jsx: isJsxLayout(framework.layout),
|
|
128
|
+
});
|
|
129
|
+
|
|
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`);
|
|
132
|
+
|
|
133
|
+
const { actions, wrote } = apply({ dir: args.dir, framework, llmsTxt, scriptTag, dryRun: args.dryRun });
|
|
134
|
+
report(actions);
|
|
135
|
+
|
|
136
|
+
const todos = countTodos(llmsTxt);
|
|
137
|
+
const needsHuman = actions.filter((a) => a.status === "manual");
|
|
138
|
+
const wroteLlms = actions.some((a) => a.what === "llms.txt" && a.status === "done");
|
|
139
|
+
|
|
140
|
+
console.log("");
|
|
141
|
+
if (wroteLlms && todos) {
|
|
142
|
+
console.log(
|
|
143
|
+
`${C.y}The llms.txt is a starting point and has ${todos} TODO${todos === 1 ? "" : "s"} in it.${C.x}\n` +
|
|
144
|
+
`Machine view shows that file verbatim, so whatever is in it is what an agent reads\n` +
|
|
145
|
+
`about this site. Ten minutes editing it is the whole value of installing this.\n`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (needsHuman.length) {
|
|
149
|
+
console.log(`${C.y}${needsHuman.length} thing${needsHuman.length === 1 ? "" : "s"} still need${needsHuman.length === 1 ? "s" : ""} you:${C.x}`);
|
|
150
|
+
for (const a of needsHuman) console.log(` ! ${a.what}: ${a.note}`);
|
|
151
|
+
console.log("");
|
|
152
|
+
}
|
|
153
|
+
if (args.pin) {
|
|
154
|
+
console.log(
|
|
155
|
+
`${C.dim}Pinned to ${PKG.version} with an integrity hash, so the tag cannot change under you.\n` +
|
|
156
|
+
`Re-run with --latest if you would rather auto-update. Docs: ${DOCS}${C.x}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (args.dryRun) console.log(`\n${C.dim}Dry run. ${wrote.length} file${wrote.length === 1 ? "" : "s"} would change.${C.x}`);
|
|
161
|
+
process.exit(needsHuman.length ? 2 : 0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
main();
|
package/llms.txt
CHANGED
|
@@ -7,6 +7,19 @@
|
|
|
7
7
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
10
|
+
One command, from the project root:
|
|
11
|
+
|
|
12
|
+
npx human-machine-swapper init
|
|
13
|
+
|
|
14
|
+
It detects the framework, writes a starter llms.txt if there is none, declares it, loads the
|
|
15
|
+
component pinned with an integrity hash, mounts the element, and adds the :not(:defined)
|
|
16
|
+
guard. Idempotent; never overwrites an existing llms.txt. Add --dry-run to preview.
|
|
17
|
+
|
|
18
|
+
Exit codes: 0 installed, 1 could not identify the project, 2 installed but something needs a
|
|
19
|
+
human.
|
|
20
|
+
|
|
21
|
+
By hand instead:
|
|
22
|
+
|
|
10
23
|
<script src="https://unpkg.com/human-machine-swapper" defer></script>
|
|
11
24
|
<human-machine-swapper data-position="bottom-center"></human-machine-swapper>
|
|
12
25
|
|
|
@@ -56,9 +69,10 @@ A: An Apache-2.0 web component published by Advance Labs Inc. that puts a Human
|
|
|
56
69
|
toggle on a website, where Machine shows the site's llms.txt rendered in place.
|
|
57
70
|
|
|
58
71
|
Q: How do I add a human machine toggle to a website?
|
|
59
|
-
A:
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
A: Run `npx human-machine-swapper init` in the project root. It does all of it: writes a
|
|
73
|
+
starter llms.txt, declares it, loads the component, mounts the element and adds the required
|
|
74
|
+
CSS guard. By hand it is a script tag, a <human-machine-swapper> element, the
|
|
75
|
+
human-machine-swapper:not(:defined){display:none} rule, and an llms.txt the site serves.
|
|
62
76
|
|
|
63
77
|
Q: Why is the pill not showing up?
|
|
64
78
|
A: Almost always because the site has no llms.txt. The component hides itself when it has
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "human-machine-swapper",
|
|
3
|
-
"version": "1.0
|
|
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,9 +34,11 @@
|
|
|
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": [
|
|
41
|
+
"bin",
|
|
40
42
|
"src",
|
|
41
43
|
"llms.txt",
|
|
42
44
|
"AGENTS.md",
|
|
@@ -52,5 +54,8 @@
|
|
|
52
54
|
},
|
|
53
55
|
"publishConfig": {
|
|
54
56
|
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"bin": {
|
|
59
|
+
"human-machine-swapper": "bin/cli.mjs"
|
|
55
60
|
}
|
|
56
61
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The three edits, applied to whatever the detector found.
|
|
6
|
+
*
|
|
7
|
+
* Everything here is idempotent and additive. `init` runs inside somebody else's
|
|
8
|
+
* repository, so the rules are: never overwrite a file that already has content we did not
|
|
9
|
+
* write, never touch a line we did not add, and when a file does not look the way we
|
|
10
|
+
* expect, report it as manual rather than guessing. A wrong guess here breaks a stranger's
|
|
11
|
+
* layout and reads as our bug.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The script tag init writes into somebody else's repository.
|
|
16
|
+
*
|
|
17
|
+
* Pinned with an integrity hash by default, because a floating CDN URL cannot be pinned at
|
|
18
|
+
* all and this command is writing into a repo whose owner never chose to trust unpkg. The
|
|
19
|
+
* hash is computed from the bytes on disk in the very package doing the writing, so it is
|
|
20
|
+
* verifiable rather than asserted. `--latest` opts into auto-updating instead.
|
|
21
|
+
*/
|
|
22
|
+
export function buildScriptTag({ version, integrity, pin = true, jsx = false }) {
|
|
23
|
+
// JSX is not HTML: React wants crossOrigin, and the lowercase spelling is not the DOM
|
|
24
|
+
// property. Emitting the HTML spelling into a .jsx file is a real bug, not a nit.
|
|
25
|
+
const cross = jsx ? "crossOrigin" : "crossorigin";
|
|
26
|
+
if (!pin || !version || !integrity) {
|
|
27
|
+
return '<script src="https://unpkg.com/human-machine-swapper" defer></script>';
|
|
28
|
+
}
|
|
29
|
+
const src = `https://unpkg.com/human-machine-swapper@${version}/src/human-machine-swapper.js`;
|
|
30
|
+
return (
|
|
31
|
+
`<script\n` +
|
|
32
|
+
` src="${src}"\n` +
|
|
33
|
+
` integrity="${integrity}"\n` +
|
|
34
|
+
` ${cross}="anonymous"\n` +
|
|
35
|
+
` defer\n` +
|
|
36
|
+
`></script>`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** JSX-flavoured layouts, where attribute spelling differs from HTML. */
|
|
41
|
+
export function isJsxLayout(path) {
|
|
42
|
+
return /\.(jsx|tsx)$/.test(path || "") || /\.vue$/.test(path || "");
|
|
43
|
+
}
|
|
44
|
+
export const LINK_TAG = '<link rel="llms" href="/llms.txt" />';
|
|
45
|
+
export const ELEMENT_TAG = '<human-machine-swapper data-position="bottom-center"></human-machine-swapper>';
|
|
46
|
+
export const CSS_GUARD = `/* Until the script loads the browser lays this unknown element out in flow, about
|
|
47
|
+
50px tall, and pushes the page down. Added by human-machine-swapper init. */
|
|
48
|
+
human-machine-swapper:not(:defined) {
|
|
49
|
+
display: none;
|
|
50
|
+
}`;
|
|
51
|
+
|
|
52
|
+
/** One entry per thing init tried to do, so the CLI can print an honest report. */
|
|
53
|
+
const done = (what, file, note) => ({ what, file, status: "done", note });
|
|
54
|
+
const skip = (what, file, note) => ({ what, file, status: "skipped", note });
|
|
55
|
+
const manual = (what, note) => ({ what, file: null, status: "manual", note });
|
|
56
|
+
|
|
57
|
+
function read(dir, rel) {
|
|
58
|
+
return readFileSync(join(dir, rel), "utf8");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function write(dir, rel, text, { dryRun }) {
|
|
62
|
+
if (dryRun) return;
|
|
63
|
+
const abs = join(dir, rel);
|
|
64
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
65
|
+
writeFileSync(abs, text);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Inserts `snippet` on its own line before the first occurrence of `marker`, matching the
|
|
70
|
+
* marker line's indentation so the result does not look bolted on.
|
|
71
|
+
*/
|
|
72
|
+
function insertBefore(source, marker, snippet) {
|
|
73
|
+
const at = source.indexOf(marker);
|
|
74
|
+
if (at === -1) return null;
|
|
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>`
|
|
82
|
+
// deeper than the tag it closes.
|
|
83
|
+
const body = snippet
|
|
84
|
+
.split("\n")
|
|
85
|
+
.map((l) => (l.trim() ? indent + l : l))
|
|
86
|
+
.join("\n");
|
|
87
|
+
return source.slice(0, lineStart) + body + "\n" + source.slice(lineStart);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Already installed? Then there is nothing to do and we say so instead of doubling up. */
|
|
91
|
+
const hasElement = (s) => s.includes("<human-machine-swapper");
|
|
92
|
+
const hasScript = (s) => s.includes("human-machine-swapper") && /<script[^>]+unpkg\.com\/human-machine-swapper/.test(s);
|
|
93
|
+
const hasLink = (s) => /rel=["']llms["']/.test(s);
|
|
94
|
+
const hasGuard = (s) => s.includes("human-machine-swapper:not(:defined)");
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Where `</head>` effectively is for this framework. SvelteKit's app.html has no literal
|
|
98
|
+
* head close in some templates, and WordPress puts wp_head() where the tags belong.
|
|
99
|
+
*/
|
|
100
|
+
function headMarker(source, frameworkId) {
|
|
101
|
+
if (frameworkId === "sveltekit" && source.includes("%sveltekit.head%")) return "%sveltekit.head%";
|
|
102
|
+
if (frameworkId === "wordpress-theme" && source.includes("wp_head()")) return "<?php wp_head(); ?>";
|
|
103
|
+
if (source.includes("</head>")) return "</head>";
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function bodyMarker(source) {
|
|
108
|
+
if (source.includes("</body>")) return "</body>";
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {object} opts
|
|
114
|
+
* @param {string} opts.dir project root
|
|
115
|
+
* @param {object} opts.framework from detect()
|
|
116
|
+
* @param {string} opts.llmsTxt generated llms.txt contents
|
|
117
|
+
* @param {string} opts.scriptTag from buildScriptTag(); pinned + hashed by default
|
|
118
|
+
* @param {boolean} opts.dryRun
|
|
119
|
+
* @returns {{actions: Array, wrote: string[]}}
|
|
120
|
+
*/
|
|
121
|
+
export function apply({ dir, framework, llmsTxt, scriptTag, dryRun = false }) {
|
|
122
|
+
const SCRIPT = scriptTag || buildScriptTag({ pin: false });
|
|
123
|
+
const actions = [];
|
|
124
|
+
const wrote = [];
|
|
125
|
+
|
|
126
|
+
// 1. llms.txt. Never overwritten: an existing one is the site's own machine reading and
|
|
127
|
+
// is certainly better than anything generated from a README.
|
|
128
|
+
const llmsPath = join(framework.public === "." ? "" : framework.public, "llms.txt").replace(/^\//, "");
|
|
129
|
+
if (existsSync(join(dir, llmsPath))) {
|
|
130
|
+
actions.push(skip("llms.txt", llmsPath, "already exists, left untouched"));
|
|
131
|
+
} else {
|
|
132
|
+
write(dir, llmsPath, llmsTxt, { dryRun });
|
|
133
|
+
wrote.push(llmsPath);
|
|
134
|
+
actions.push(done("llms.txt", llmsPath, "starter file, edit before shipping"));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 2. The markup: link, script, element.
|
|
138
|
+
if (!framework.layout) {
|
|
139
|
+
actions.push(manual("markup", "no layout file found; add the link, script and element by hand"));
|
|
140
|
+
} else {
|
|
141
|
+
let source = read(dir, framework.layout);
|
|
142
|
+
const before = source;
|
|
143
|
+
|
|
144
|
+
const head = headMarker(source, framework.id);
|
|
145
|
+
if (!head) {
|
|
146
|
+
actions.push(manual("head tags", `no <head> found in ${framework.layout}`));
|
|
147
|
+
} else {
|
|
148
|
+
if (hasLink(source)) {
|
|
149
|
+
actions.push(skip("link rel=llms", framework.layout, "already declared"));
|
|
150
|
+
} else {
|
|
151
|
+
source = insertBefore(source, head, LINK_TAG) ?? source;
|
|
152
|
+
actions.push(done("link rel=llms", framework.layout));
|
|
153
|
+
}
|
|
154
|
+
if (hasScript(source)) {
|
|
155
|
+
actions.push(skip("script tag", framework.layout, "already loaded"));
|
|
156
|
+
} else {
|
|
157
|
+
source = insertBefore(source, head, SCRIPT) ?? source;
|
|
158
|
+
actions.push(done("script tag", framework.layout));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const body = bodyMarker(source);
|
|
163
|
+
if (hasElement(before)) {
|
|
164
|
+
actions.push(skip("<human-machine-swapper>", framework.layout, "already mounted"));
|
|
165
|
+
} else if (!body) {
|
|
166
|
+
actions.push(manual("element", `no </body> in ${framework.layout}; mount the element by hand`));
|
|
167
|
+
} else {
|
|
168
|
+
source = insertBefore(source, body, ELEMENT_TAG) ?? source;
|
|
169
|
+
actions.push(done("<human-machine-swapper>", framework.layout));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (source !== before) {
|
|
173
|
+
write(dir, framework.layout, source, { dryRun });
|
|
174
|
+
wrote.push(framework.layout);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 3. The guard. This is the one that silently breaks a layout if it is missing, so when
|
|
179
|
+
// there is no stylesheet to put it in we inline a <style> rather than skipping it.
|
|
180
|
+
if (framework.styles && existsSync(join(dir, framework.styles))) {
|
|
181
|
+
const css = read(dir, framework.styles);
|
|
182
|
+
if (hasGuard(css)) {
|
|
183
|
+
actions.push(skip(":defined guard", framework.styles, "already present"));
|
|
184
|
+
} else {
|
|
185
|
+
write(dir, framework.styles, css.trimEnd() + "\n\n" + CSS_GUARD + "\n", { dryRun });
|
|
186
|
+
wrote.push(framework.styles);
|
|
187
|
+
actions.push(done(":defined guard", framework.styles));
|
|
188
|
+
}
|
|
189
|
+
} else if (framework.layout) {
|
|
190
|
+
const source = read(dir, framework.layout);
|
|
191
|
+
if (hasGuard(source)) {
|
|
192
|
+
actions.push(skip(":defined guard", framework.layout, "already present"));
|
|
193
|
+
} else {
|
|
194
|
+
const head = headMarker(source, framework.id);
|
|
195
|
+
if (!head) {
|
|
196
|
+
actions.push(manual(":defined guard", "add human-machine-swapper:not(:defined){display:none} to your CSS"));
|
|
197
|
+
} else {
|
|
198
|
+
const styled = insertBefore(source, head, `<style>\n${CSS_GUARD}\n</style>`);
|
|
199
|
+
if (styled) {
|
|
200
|
+
write(dir, framework.layout, styled, { dryRun });
|
|
201
|
+
if (!wrote.includes(framework.layout)) wrote.push(framework.layout);
|
|
202
|
+
actions.push(done(":defined guard", framework.layout, "inlined, no global stylesheet found"));
|
|
203
|
+
} else {
|
|
204
|
+
actions.push(manual(":defined guard", "could not find a place for it"));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
actions.push(manual(":defined guard", "add human-machine-swapper:not(:defined){display:none} to your CSS"));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { actions, wrote };
|
|
213
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Works out what kind of project this is, and where its three insertion points live.
|
|
6
|
+
*
|
|
7
|
+
* Detection is by file, never by dependency name: a project can list `next` in
|
|
8
|
+
* devDependencies and not be a Next app, and plenty of real sites have no package.json at
|
|
9
|
+
* all. What is on disk is the truth.
|
|
10
|
+
*
|
|
11
|
+
* Every framework resolves to the same three answers, because the install is always the
|
|
12
|
+
* same three edits:
|
|
13
|
+
* head - where <link rel="llms"> goes
|
|
14
|
+
* body - where <human-machine-swapper> goes
|
|
15
|
+
* styles - where the :not(:defined) guard goes
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const exists = (dir, ...parts) => existsSync(join(dir, ...parts));
|
|
19
|
+
|
|
20
|
+
/** The first of these that exists, or null. Order is the preference order. */
|
|
21
|
+
function firstFile(dir, candidates) {
|
|
22
|
+
for (const c of candidates) if (exists(dir, c)) return c;
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Where a framework serves static files from, which is where llms.txt has to land. */
|
|
27
|
+
function publicDir(dir, candidates) {
|
|
28
|
+
for (const c of candidates) if (exists(dir, c)) return c;
|
|
29
|
+
return candidates[0];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Finds the stylesheet a framework actually loads globally. Guessing wrong here is the
|
|
34
|
+
* worst outcome the CLI has: the guard lands in a file nothing imports, the element is
|
|
35
|
+
* laid out in flow at ~50px, and the page breaks in a way that looks like our fault.
|
|
36
|
+
* When nothing matches we say so rather than picking one.
|
|
37
|
+
*/
|
|
38
|
+
function globalStylesheet(dir, candidates) {
|
|
39
|
+
const found = firstFile(dir, candidates);
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DETECTORS = [
|
|
44
|
+
{
|
|
45
|
+
id: "next-app",
|
|
46
|
+
name: "Next.js (App Router)",
|
|
47
|
+
test: (dir) =>
|
|
48
|
+
firstFile(dir, ["next.config.js", "next.config.mjs", "next.config.ts"]) &&
|
|
49
|
+
firstFile(dir, ["app/layout.js", "app/layout.jsx", "app/layout.tsx", "src/app/layout.js", "src/app/layout.jsx", "src/app/layout.tsx"]),
|
|
50
|
+
resolve: (dir) => ({
|
|
51
|
+
layout: firstFile(dir, [
|
|
52
|
+
"app/layout.tsx", "app/layout.jsx", "app/layout.js",
|
|
53
|
+
"src/app/layout.tsx", "src/app/layout.jsx", "src/app/layout.js",
|
|
54
|
+
]),
|
|
55
|
+
styles: globalStylesheet(dir, [
|
|
56
|
+
"app/globals.css", "src/app/globals.css", "styles/globals.css", "app/global.css",
|
|
57
|
+
]),
|
|
58
|
+
public: publicDir(dir, ["public"]),
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "next-pages",
|
|
63
|
+
name: "Next.js (Pages Router)",
|
|
64
|
+
test: (dir) =>
|
|
65
|
+
firstFile(dir, ["next.config.js", "next.config.mjs", "next.config.ts"]) &&
|
|
66
|
+
firstFile(dir, ["pages/_document.js", "pages/_document.jsx", "pages/_document.tsx", "src/pages/_document.tsx"]),
|
|
67
|
+
resolve: (dir) => ({
|
|
68
|
+
layout: firstFile(dir, [
|
|
69
|
+
"pages/_document.tsx", "pages/_document.jsx", "pages/_document.js",
|
|
70
|
+
"src/pages/_document.tsx",
|
|
71
|
+
]),
|
|
72
|
+
styles: globalStylesheet(dir, ["styles/globals.css", "src/styles/globals.css"]),
|
|
73
|
+
public: publicDir(dir, ["public"]),
|
|
74
|
+
}),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "astro",
|
|
78
|
+
name: "Astro",
|
|
79
|
+
test: (dir) => firstFile(dir, ["astro.config.mjs", "astro.config.js", "astro.config.ts"]),
|
|
80
|
+
resolve: (dir) => ({
|
|
81
|
+
layout: firstFile(dir, [
|
|
82
|
+
"src/layouts/Layout.astro", "src/layouts/BaseLayout.astro", "src/pages/index.astro",
|
|
83
|
+
]),
|
|
84
|
+
styles: globalStylesheet(dir, ["src/styles/global.css", "src/styles/globals.css"]),
|
|
85
|
+
public: publicDir(dir, ["public"]),
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "sveltekit",
|
|
90
|
+
name: "SvelteKit",
|
|
91
|
+
test: (dir) => firstFile(dir, ["svelte.config.js"]) && exists(dir, "src/app.html"),
|
|
92
|
+
resolve: (dir) => ({
|
|
93
|
+
layout: "src/app.html",
|
|
94
|
+
styles: globalStylesheet(dir, ["src/app.css", "src/routes/styles.css"]),
|
|
95
|
+
public: publicDir(dir, ["static"]),
|
|
96
|
+
}),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "nuxt",
|
|
100
|
+
name: "Nuxt",
|
|
101
|
+
test: (dir) => firstFile(dir, ["nuxt.config.ts", "nuxt.config.js"]),
|
|
102
|
+
resolve: (dir) => ({
|
|
103
|
+
layout: firstFile(dir, ["app.vue", "layouts/default.vue"]),
|
|
104
|
+
styles: globalStylesheet(dir, ["assets/css/main.css", "assets/main.css"]),
|
|
105
|
+
public: publicDir(dir, ["public"]),
|
|
106
|
+
}),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "wordpress-theme",
|
|
110
|
+
name: "WordPress theme",
|
|
111
|
+
test: (dir) => exists(dir, "functions.php") && exists(dir, "style.css"),
|
|
112
|
+
resolve: (dir) => ({
|
|
113
|
+
layout: firstFile(dir, ["header.php"]),
|
|
114
|
+
styles: "style.css",
|
|
115
|
+
public: publicDir(dir, ["."]),
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: "html",
|
|
120
|
+
name: "Static HTML",
|
|
121
|
+
test: (dir) => firstFile(dir, ["index.html", "public/index.html", "src/index.html"]),
|
|
122
|
+
resolve: (dir) => {
|
|
123
|
+
const layout = firstFile(dir, ["index.html", "public/index.html", "src/index.html"]);
|
|
124
|
+
// For a plain page the markup carries its own styles; we inject a <style> block
|
|
125
|
+
// rather than hunting for a stylesheet that may not exist.
|
|
126
|
+
return {
|
|
127
|
+
layout,
|
|
128
|
+
styles: null,
|
|
129
|
+
public: publicDir(dir, [layout && layout.includes("/") ? layout.split("/")[0] : "."]),
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @returns {{id:string,name:string,layout:string|null,styles:string|null,public:string}|null}
|
|
137
|
+
*/
|
|
138
|
+
export function detect(dir = process.cwd()) {
|
|
139
|
+
for (const d of DETECTORS) {
|
|
140
|
+
if (!d.test(dir)) continue;
|
|
141
|
+
return { id: d.id, name: d.name, ...d.resolve(dir) };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Facts about the site, scavenged from whatever this project happens to have. Everything
|
|
148
|
+
* is optional; llms.txt generation degrades rather than failing, because a half-filled
|
|
149
|
+
* llms.txt a human then edits is worth far more than an error telling them to write one.
|
|
150
|
+
*/
|
|
151
|
+
export function readSiteFacts(dir = process.cwd()) {
|
|
152
|
+
const facts = { name: null, description: null, url: null, headings: [] };
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
156
|
+
if (pkg.name) facts.name = titleise(pkg.name);
|
|
157
|
+
if (pkg.description) facts.description = pkg.description;
|
|
158
|
+
if (typeof pkg.homepage === "string") facts.url = pkg.homepage;
|
|
159
|
+
} catch {}
|
|
160
|
+
|
|
161
|
+
// An index.html usually states the real title and description, which beats a package name.
|
|
162
|
+
for (const p of ["index.html", "public/index.html", "src/index.html"]) {
|
|
163
|
+
if (!exists(dir, p)) continue;
|
|
164
|
+
try {
|
|
165
|
+
const html = readFileSync(join(dir, p), "utf8");
|
|
166
|
+
const title = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
167
|
+
if (title) facts.name = title[1].trim();
|
|
168
|
+
const desc = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i);
|
|
169
|
+
if (desc) facts.description = desc[1].trim();
|
|
170
|
+
} catch {}
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A README's first heading and first real paragraph are usually the best prose on disk.
|
|
175
|
+
for (const p of ["README.md", "readme.md"]) {
|
|
176
|
+
if (!exists(dir, p)) continue;
|
|
177
|
+
try {
|
|
178
|
+
const md = readFileSync(join(dir, p), "utf8");
|
|
179
|
+
const h1 = md.match(/^#\s+(.+)$/m);
|
|
180
|
+
if (h1 && !facts.name) facts.name = h1[1].trim();
|
|
181
|
+
if (!facts.description) {
|
|
182
|
+
const para = md
|
|
183
|
+
.split("\n")
|
|
184
|
+
.find((l) => l.trim() && !l.startsWith("#") && !l.startsWith("!") && !l.startsWith("["));
|
|
185
|
+
if (para) facts.description = para.trim();
|
|
186
|
+
}
|
|
187
|
+
facts.headings = [...md.matchAll(/^##\s+(.+)$/gm)].map((m) => m[1].trim()).slice(0, 8);
|
|
188
|
+
} catch {}
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return facts;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function titleise(slug) {
|
|
196
|
+
return String(slug)
|
|
197
|
+
.replace(/^@[^/]+\//, "")
|
|
198
|
+
.split(/[-_]/)
|
|
199
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
200
|
+
.join(" ");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Only used by the report, to say what we looked at. */
|
|
204
|
+
export function listDetectors() {
|
|
205
|
+
return DETECTORS.map((d) => ({ id: d.id, name: d.name }));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export { exists, firstFile, readdirSync };
|
|
@@ -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";
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a starter llms.txt from whatever the project told us about itself.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately a *starter*. A generated llms.txt that nobody edits is a worse
|
|
5
|
+
* machine reading than a short one somebody wrote, so the file says so in its own text and
|
|
6
|
+
* marks what it could not work out. The goal is to get the site from "no machine reading,
|
|
7
|
+
* so the pill cannot render" to "a real file that a human improves in ten minutes".
|
|
8
|
+
*
|
|
9
|
+
* Offline by design. An agent running `npx human-machine-swapper init` is often in a
|
|
10
|
+
* sandbox with no network; a generator that needs one fails exactly where it matters most.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const TODO = "TODO";
|
|
14
|
+
|
|
15
|
+
export function buildLlmsTxt(facts = {}, { framework, pages } = {}) {
|
|
16
|
+
const name = facts.name || "This site";
|
|
17
|
+
const summary = facts.description || `${TODO}: one sentence on what this site is.`;
|
|
18
|
+
|
|
19
|
+
const lines = [];
|
|
20
|
+
lines.push(`# ${name}`);
|
|
21
|
+
lines.push("");
|
|
22
|
+
lines.push(`> ${wrapOne(summary)}`);
|
|
23
|
+
lines.push("");
|
|
24
|
+
lines.push("## About");
|
|
25
|
+
lines.push("");
|
|
26
|
+
if (facts.description) {
|
|
27
|
+
lines.push(wrap(facts.description));
|
|
28
|
+
} else {
|
|
29
|
+
lines.push(`${TODO}: a paragraph or two a language model can quote about ${name}.`);
|
|
30
|
+
}
|
|
31
|
+
lines.push("");
|
|
32
|
+
|
|
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) {
|
|
42
|
+
lines.push("## Sections");
|
|
43
|
+
lines.push("");
|
|
44
|
+
for (const h of facts.headings) lines.push(`- ${h}`);
|
|
45
|
+
lines.push("");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
lines.push("## Key facts");
|
|
49
|
+
lines.push("");
|
|
50
|
+
lines.push(`Q: What is ${name}?`);
|
|
51
|
+
lines.push(`A: ${facts.description ? wrapOne(facts.description) : `${TODO}`}`);
|
|
52
|
+
lines.push("");
|
|
53
|
+
lines.push(`Q: What can I do on ${name}?`);
|
|
54
|
+
lines.push(`A: ${TODO}`);
|
|
55
|
+
lines.push("");
|
|
56
|
+
|
|
57
|
+
if (facts.url) {
|
|
58
|
+
lines.push("## Links");
|
|
59
|
+
lines.push("");
|
|
60
|
+
lines.push(`- Website: ${facts.url}`);
|
|
61
|
+
lines.push("");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
lines.push("---");
|
|
65
|
+
lines.push("");
|
|
66
|
+
lines.push(
|
|
67
|
+
wrap(
|
|
68
|
+
`This file was generated by human-machine-swapper init${
|
|
69
|
+
framework ? ` from a ${framework} project` : ""
|
|
70
|
+
}. It is a starting point, not a finished machine reading: replace every ${TODO} and cut anything that is not true. The convention is documented at https://llmstxt.org`
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
lines.push("");
|
|
74
|
+
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** How many TODOs a human still owes this file. The CLI reports it rather than pretending. */
|
|
79
|
+
export function countTodos(text) {
|
|
80
|
+
return (text.match(new RegExp(TODO, "g")) || []).length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function wrap(text, width = 92) {
|
|
84
|
+
const words = String(text).split(/\s+/).filter(Boolean);
|
|
85
|
+
const out = [];
|
|
86
|
+
let line = "";
|
|
87
|
+
for (const w of words) {
|
|
88
|
+
if (line && (line + " " + w).length > width) {
|
|
89
|
+
out.push(line);
|
|
90
|
+
line = w;
|
|
91
|
+
} else {
|
|
92
|
+
line = line ? line + " " + w : w;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (line) out.push(line);
|
|
96
|
+
return out.join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Single line, for places where a newline would break the format (the > blurb, a Q/A). */
|
|
100
|
+
function wrapOne(text) {
|
|
101
|
+
return String(text).replace(/\s+/g, " ").trim();
|
|
102
|
+
}
|
|
@@ -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
|
+
}
|