docspack 0.0.1 → 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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/bin/docspack.js +25 -0
  4. package/dist/build.d.ts +31 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/build.js +435 -0
  7. package/dist/build.js.map +1 -0
  8. package/dist/cli.d.ts +3 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +763 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/config.d.ts +41 -0
  13. package/dist/config.d.ts.map +1 -0
  14. package/dist/config.js +118 -0
  15. package/dist/config.js.map +1 -0
  16. package/dist/db.d.ts +60 -0
  17. package/dist/db.d.ts.map +1 -0
  18. package/dist/db.js +204 -0
  19. package/dist/db.js.map +1 -0
  20. package/dist/discovery.d.ts +31 -0
  21. package/dist/discovery.d.ts.map +1 -0
  22. package/dist/discovery.js +126 -0
  23. package/dist/discovery.js.map +1 -0
  24. package/dist/doctor.d.ts +25 -0
  25. package/dist/doctor.d.ts.map +1 -0
  26. package/dist/doctor.js +276 -0
  27. package/dist/doctor.js.map +1 -0
  28. package/dist/document.d.ts +13 -0
  29. package/dist/document.d.ts.map +1 -0
  30. package/dist/document.js +47 -0
  31. package/dist/document.js.map +1 -0
  32. package/dist/errors.d.ts +9 -0
  33. package/dist/errors.d.ts.map +1 -0
  34. package/dist/errors.js +10 -0
  35. package/dist/errors.js.map +1 -0
  36. package/dist/exports.d.ts +20 -0
  37. package/dist/exports.d.ts.map +1 -0
  38. package/dist/exports.js +100 -0
  39. package/dist/exports.js.map +1 -0
  40. package/dist/feedback.d.ts +68 -0
  41. package/dist/feedback.d.ts.map +1 -0
  42. package/dist/feedback.js +0 -0
  43. package/dist/feedback.js.map +1 -0
  44. package/dist/html.d.ts +4 -0
  45. package/dist/html.d.ts.map +1 -0
  46. package/dist/html.js +23 -0
  47. package/dist/html.js.map +1 -0
  48. package/dist/http.d.ts +30 -0
  49. package/dist/http.d.ts.map +1 -0
  50. package/dist/http.js +144 -0
  51. package/dist/http.js.map +1 -0
  52. package/dist/index.d.ts +25 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +25 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/init/detect.d.ts +16 -0
  57. package/dist/init/detect.d.ts.map +1 -0
  58. package/dist/init/detect.js +120 -0
  59. package/dist/init/detect.js.map +1 -0
  60. package/dist/init/plan.d.ts +43 -0
  61. package/dist/init/plan.d.ts.map +1 -0
  62. package/dist/init/plan.js +145 -0
  63. package/dist/init/plan.js.map +1 -0
  64. package/dist/init/run.d.ts +28 -0
  65. package/dist/init/run.d.ts.map +1 -0
  66. package/dist/init/run.js +96 -0
  67. package/dist/init/run.js.map +1 -0
  68. package/dist/init/templates.d.ts +24 -0
  69. package/dist/init/templates.d.ts.map +1 -0
  70. package/dist/init/templates.js +181 -0
  71. package/dist/init/templates.js.map +1 -0
  72. package/dist/init/write.d.ts +20 -0
  73. package/dist/init/write.d.ts.map +1 -0
  74. package/dist/init/write.js +56 -0
  75. package/dist/init/write.js.map +1 -0
  76. package/dist/kinds.d.ts +14 -0
  77. package/dist/kinds.d.ts.map +1 -0
  78. package/dist/kinds.js +15 -0
  79. package/dist/kinds.js.map +1 -0
  80. package/dist/llms-txt.d.ts +25 -0
  81. package/dist/llms-txt.d.ts.map +1 -0
  82. package/dist/llms-txt.js +94 -0
  83. package/dist/llms-txt.js.map +1 -0
  84. package/dist/mcp.d.ts +15 -0
  85. package/dist/mcp.d.ts.map +1 -0
  86. package/dist/mcp.js +158 -0
  87. package/dist/mcp.js.map +1 -0
  88. package/dist/preview.d.ts +18 -0
  89. package/dist/preview.d.ts.map +1 -0
  90. package/dist/preview.js +72 -0
  91. package/dist/preview.js.map +1 -0
  92. package/dist/prompt.d.ts +27 -0
  93. package/dist/prompt.d.ts.map +1 -0
  94. package/dist/prompt.js +79 -0
  95. package/dist/prompt.js.map +1 -0
  96. package/dist/search.d.ts +41 -0
  97. package/dist/search.d.ts.map +1 -0
  98. package/dist/search.js +60 -0
  99. package/dist/search.js.map +1 -0
  100. package/dist/snippet.d.ts +20 -0
  101. package/dist/snippet.d.ts.map +1 -0
  102. package/dist/snippet.js +29 -0
  103. package/dist/snippet.js.map +1 -0
  104. package/dist/spec.d.ts +38 -0
  105. package/dist/spec.d.ts.map +1 -0
  106. package/dist/spec.js +105 -0
  107. package/dist/spec.js.map +1 -0
  108. package/dist/style.d.ts +33 -0
  109. package/dist/style.d.ts.map +1 -0
  110. package/dist/style.js +94 -0
  111. package/dist/style.js.map +1 -0
  112. package/dist/submit.d.ts +61 -0
  113. package/dist/submit.d.ts.map +1 -0
  114. package/dist/submit.js +111 -0
  115. package/dist/submit.js.map +1 -0
  116. package/dist/sync.d.ts +29 -0
  117. package/dist/sync.d.ts.map +1 -0
  118. package/dist/sync.js +73 -0
  119. package/dist/sync.js.map +1 -0
  120. package/dist/verify.d.ts +44 -0
  121. package/dist/verify.d.ts.map +1 -0
  122. package/dist/verify.js +291 -0
  123. package/dist/verify.js.map +1 -0
  124. package/package.json +60 -5
  125. package/src/build.ts +572 -0
  126. package/src/cli.ts +883 -0
  127. package/src/config.ts +158 -0
  128. package/src/db.ts +261 -0
  129. package/src/discovery.ts +161 -0
  130. package/src/doctor.ts +344 -0
  131. package/src/document.ts +59 -0
  132. package/src/errors.ts +10 -0
  133. package/src/exports.ts +120 -0
  134. package/src/feedback.ts +0 -0
  135. package/src/html.ts +24 -0
  136. package/src/http.ts +190 -0
  137. package/src/index.ts +132 -0
  138. package/src/init/detect.ts +142 -0
  139. package/src/init/plan.ts +215 -0
  140. package/src/init/run.ts +142 -0
  141. package/src/init/templates.ts +200 -0
  142. package/src/init/write.ts +83 -0
  143. package/src/kinds.ts +17 -0
  144. package/src/llms-txt.ts +116 -0
  145. package/src/mcp.ts +196 -0
  146. package/src/preview.ts +98 -0
  147. package/src/prompt.ts +103 -0
  148. package/src/search.ts +96 -0
  149. package/src/snippet.ts +30 -0
  150. package/src/spec.ts +138 -0
  151. package/src/style.ts +111 -0
  152. package/src/submit.ts +189 -0
  153. package/src/sync.ts +112 -0
  154. package/src/verify.ts +355 -0
  155. package/bin/cli.js +0 -2
@@ -0,0 +1,142 @@
1
+ import { join } from "node:path";
2
+ import { type BuildResult, buildPackage } from "../build.js";
3
+ import { type DoctorReport, runDoctor } from "../doctor.js";
4
+ import { Prompter } from "../prompt.js";
5
+ import { type Detected, detectProject } from "./detect.js";
6
+ import { type InitOptions, type InitPlan, planInit, proposePackageName } from "./plan.js";
7
+ import { applyPlan, type WrittenFile } from "./write.js";
8
+
9
+ export interface RunInitOptions extends InitOptions {
10
+ readonly cwd: string;
11
+ /** Skip every prompt and use flags plus detected defaults. */
12
+ readonly yes?: boolean;
13
+ readonly dryRun?: boolean;
14
+ readonly force?: boolean;
15
+ /** Run the first build after scaffolding. Defaults to true. */
16
+ readonly build?: boolean;
17
+ readonly prompter?: Prompter;
18
+ readonly onProgress?: (message: string) => void;
19
+ }
20
+
21
+ export interface InitResult {
22
+ readonly detected: Detected;
23
+ readonly plan: InitPlan;
24
+ readonly written: readonly WrittenFile[];
25
+ readonly build?: BuildResult;
26
+ readonly doctor?: DoctorReport;
27
+ readonly cancelled: boolean;
28
+ }
29
+
30
+ /** detect → propose → confirm → write → build → doctor. */
31
+ export async function runInit(options: RunInitOptions): Promise<InitResult> {
32
+ const detected = await detectProject(options.cwd);
33
+ const prompter = options.prompter ?? new Prompter();
34
+ const interactive = options.yes !== true && prompter.interactive;
35
+
36
+ const answers = interactive ? await ask(prompter, options, detected) : options;
37
+ const plan = planInit(answers, detected);
38
+
39
+ if (interactive) {
40
+ prompter.note("");
41
+ for (const note of plan.notes) prompter.note(` · ${note}`);
42
+ prompter.note("");
43
+ if (!(await prompter.confirm(`Write ${plan.files.length} files to ${plan.dir}/?`))) {
44
+ prompter.close();
45
+ return { detected, plan, written: [], cancelled: true };
46
+ }
47
+ prompter.close();
48
+ }
49
+
50
+ const written = await applyPlan(options.cwd, plan, {
51
+ ...(options.force === undefined ? {} : { force: options.force }),
52
+ ...(options.dryRun === undefined ? {} : { dryRun: options.dryRun }),
53
+ });
54
+
55
+ if (options.dryRun === true || options.build === false) {
56
+ return { detected, plan, written, cancelled: false };
57
+ }
58
+
59
+ const dir = join(options.cwd, plan.dir);
60
+ const built = await buildPackage({
61
+ out: dir,
62
+ ...(options.onProgress === undefined ? {} : { onProgress: options.onProgress }),
63
+ });
64
+ const doctor = await runDoctor({ dir });
65
+
66
+ return { detected, plan, written, build: built, doctor, cancelled: false };
67
+ }
68
+
69
+ async function ask(
70
+ prompter: Prompter,
71
+ options: RunInitOptions,
72
+ detected: Detected,
73
+ ): Promise<InitOptions> {
74
+ const community = options.community === true;
75
+ const proposed =
76
+ options.name ??
77
+ (detected.libraryName === undefined ? undefined : proposePackageName(detected, community));
78
+
79
+ if (detected.libraryName !== undefined) {
80
+ const where = [
81
+ `${detected.libraryName}${detected.libraryVersion === undefined ? "" : `@${detected.libraryVersion}`}`,
82
+ detected.docsDir === undefined
83
+ ? undefined
84
+ : `${detected.docsDir}/ (${detected.docsFiles} files)`,
85
+ detected.openapi,
86
+ ].filter((part): part is string => part !== undefined);
87
+ prompter.note(`Detected ${where.join(" · ")}\n`);
88
+ }
89
+
90
+ const name = await prompter.text("Package name", proposed ?? "@acme/docspack");
91
+ const version = await prompter.text(
92
+ "Version",
93
+ options.version ?? detected.libraryVersion ?? "0.1.0",
94
+ );
95
+
96
+ const input = await selectInput(prompter, options, detected);
97
+ const out = await prompter.text("Directory", options.out ?? "docspack");
98
+ const workflow = await prompter.confirm("Add the release workflow?", options.workflow !== false);
99
+
100
+ return {
101
+ ...options,
102
+ name,
103
+ version,
104
+ out,
105
+ workflow,
106
+ ...input,
107
+ };
108
+ }
109
+
110
+ async function selectInput(
111
+ prompter: Prompter,
112
+ options: RunInitOptions,
113
+ detected: Detected,
114
+ ): Promise<Pick<InitOptions, "from" | "openapi" | "mirror">> {
115
+ if (options.from !== undefined) return { from: options.from };
116
+ if (options.openapi !== undefined) return { openapi: options.openapi };
117
+ if (options.mirror !== undefined) return { mirror: options.mirror };
118
+
119
+ const choices: {
120
+ value: Pick<InitOptions, "from" | "openapi" | "mirror">;
121
+ label: string;
122
+ hint?: string;
123
+ }[] = [];
124
+ if (detected.docsDir !== undefined) {
125
+ choices.push({
126
+ value: { from: detected.docsDir },
127
+ label: `${detected.docsDir}/`,
128
+ hint: `${detected.docsFiles} Markdown files`,
129
+ });
130
+ }
131
+ if (detected.openapi !== undefined) {
132
+ choices.push({
133
+ value: { openapi: detected.openapi },
134
+ label: detected.openapi,
135
+ hint: "one chunk per operation",
136
+ });
137
+ }
138
+ choices.push({ value: {}, label: "start from templates", hint: "seeds docs/ with three files" });
139
+
140
+ if (choices.length === 1) return {};
141
+ return prompter.select("Documentation source", choices);
142
+ }
@@ -0,0 +1,200 @@
1
+ import { CONFIG_KEY } from "../config.js";
2
+ import { AGENTS_SNIPPET, FEEDBACK_SNIPPET } from "../snippet.js";
3
+ import { LLMS_DIR } from "../spec.js";
4
+
5
+ export interface PackageJsonInput {
6
+ readonly name: string;
7
+ readonly version: string;
8
+ readonly libraryName?: string;
9
+ readonly repository?: string;
10
+ readonly license?: string;
11
+ readonly build: Record<string, string | number>;
12
+ readonly docspackVersion: string;
13
+ }
14
+
15
+ /** Marker the doctor looks for, so a half-filled template cannot be published as documentation. */
16
+ export const PLACEHOLDER = "TODO:";
17
+
18
+ export function packageJson(input: PackageJsonInput): string {
19
+ const subject = input.libraryName ?? input.name;
20
+ return `${JSON.stringify(
21
+ {
22
+ name: input.name,
23
+ version: input.version,
24
+ description: `Documentation for ${subject}, for use with docspack.`,
25
+ keywords: ["docspack", "documentation", "ai", "agents", "mcp"],
26
+ ...(input.repository === undefined
27
+ ? {}
28
+ : { repository: { type: "git", url: `git+${input.repository}.git` } }),
29
+ ...(input.license === undefined ? {} : { license: input.license }),
30
+ // Without .llms the published package installs fine and indexes nothing.
31
+ files: [LLMS_DIR, "llms.txt"],
32
+ publishConfig: { access: "public" },
33
+ // `documents` names the library, which is what `docspack verify` checks the docs against.
34
+ [CONFIG_KEY]: {
35
+ ...(input.libraryName === undefined ? {} : { documents: input.libraryName }),
36
+ ...input.build,
37
+ },
38
+ scripts: {
39
+ build: "docspack build",
40
+ // Rebuilds and validates on every publish, so a stale payload cannot reach the registry.
41
+ prepublishOnly: "docspack build && docspack doctor --strict",
42
+ },
43
+ devDependencies: { docspack: `^${input.docspackVersion}` },
44
+ },
45
+ null,
46
+ 2,
47
+ )}\n`;
48
+ }
49
+
50
+ export function readme(name: string, libraryName: string | undefined): string {
51
+ const subject = libraryName ?? "this library";
52
+ return `# ${name}
53
+
54
+ Documentation for ${subject}, packaged for [docspack](https://github.com/docspack/docspack)
55
+ so AI coding agents can read it locally, at the version you installed.
56
+
57
+ \`\`\`bash
58
+ npm i -D ${name}
59
+ npx docspack sync
60
+ npx docspack ask "how do I authenticate"
61
+ \`\`\`
62
+
63
+ Give an agent access with one line in AGENTS.md or CLAUDE.md:
64
+
65
+ \`\`\`md
66
+ ${AGENTS_SNIPPET.join("\n")}
67
+ \`\`\`
68
+
69
+ No server needed. \`docspack mcp\` serves the same index over MCP for clients that prefer a
70
+ declared tool; both return identical text, scoped to the version this project depends on.
71
+
72
+ Add this as well if you want the agent to record documentation problems it hits:
73
+
74
+ \`\`\`md
75
+ ${FEEDBACK_SNIPPET.join("\n")}
76
+ \`\`\`
77
+
78
+ ## What is in here
79
+
80
+ \`${LLMS_DIR}/\` holds the machine-readable payload: a manifest and one Markdown file per
81
+ chunk. \`llms.txt\` is the table of contents. Both are generated — see the repository this
82
+ package is built from to change the source documentation.
83
+ `;
84
+ }
85
+
86
+ export function gitignore(): string {
87
+ return `# Generated by \`docspack build\`. Rebuilt on publish by prepublishOnly.
88
+ ${LLMS_DIR}/
89
+ llms.txt
90
+ `;
91
+ }
92
+
93
+ /**
94
+ * Three seeds that map onto how retrieval works: one chunk should answer one question. The
95
+ * guidance in them is what `docspack doctor` checks, so following it keeps the package clean.
96
+ */
97
+ export function docSeeds(subject: string): { path: string; contents: string }[] {
98
+ return [
99
+ {
100
+ path: "docs/01-overview.md",
101
+ contents: `# ${subject} overview
102
+
103
+ ${PLACEHOLDER} one paragraph on what ${subject} is and the problem it solves. Write plainly:
104
+ no marketing adjectives, no "in this guide". Every sentence should carry a fact, because the
105
+ index matches on the words someone would type.
106
+
107
+ ## Mental model
108
+
109
+ ${PLACEHOLDER} the two or three concepts a reader needs before anything else makes sense.
110
+ Name them the way your API names them, in \`inline code\` — those names are indexed.
111
+
112
+ ## When to use it
113
+
114
+ ${PLACEHOLDER} what ${subject} is for, and what it is not for. Keep sentences under 40 words:
115
+ one claim each retrieves better than two joined by a conjunction.
116
+ `,
117
+ },
118
+ {
119
+ path: "docs/02-getting-started.md",
120
+ contents: `# Getting started with ${subject}
121
+
122
+ ## Installation
123
+
124
+ ${PLACEHOLDER} the install command, and any peer requirement.
125
+
126
+ ## Configuration
127
+
128
+ ${PLACEHOLDER} the minimum configuration needed to make a first call. Put option names in
129
+ \`inline code\` — docspack indexes those as entities, which is how an agent finds them.
130
+ Show the call rather than describing it; a chunk with no code is hard to act on.
131
+
132
+ ## Your first call
133
+
134
+ ${PLACEHOLDER} a complete, runnable example. Short and real beats long and abstract.
135
+ `,
136
+ },
137
+ {
138
+ path: "docs/03-api.md",
139
+ contents: `# ${subject} API
140
+
141
+ Give each operation its own \`##\` heading: docspack splits chunks at that level, and one
142
+ chunk that answers one question retrieves far better than one that answers five.
143
+
144
+ ## ${PLACEHOLDER} first operation
145
+
146
+ ${PLACEHOLDER} what it does, its parameters, what it returns, and one example.
147
+
148
+ ## ${PLACEHOLDER} second operation
149
+
150
+ ${PLACEHOLDER} same shape as above.
151
+ `,
152
+ },
153
+ ];
154
+ }
155
+
156
+ export function workflow(name: string, buildArgs: string): string {
157
+ return `# Publishes ${name} on every release, so the documentation version always
158
+ # matches the library version. Needs an NPM_TOKEN secret with publish rights.
159
+
160
+ name: Publish documentation package
161
+
162
+ on:
163
+ release:
164
+ types: [published]
165
+ workflow_dispatch:
166
+ inputs:
167
+ version:
168
+ description: Version to publish
169
+ required: true
170
+
171
+ jobs:
172
+ publish:
173
+ runs-on: ubuntu-latest
174
+ permissions:
175
+ contents: read
176
+ id-token: write
177
+ steps:
178
+ - uses: actions/checkout@v5
179
+
180
+ - uses: actions/setup-node@v5
181
+ with:
182
+ node-version: 24
183
+ registry-url: https://registry.npmjs.org
184
+
185
+ - name: Resolve the version
186
+ id: version
187
+ run: echo "value=\${{ inputs.version || github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
188
+
189
+ - name: Generate the documentation package
190
+ run: npx docspack build${buildArgs} --pkg-version "\${{ steps.version.outputs.value }}"
191
+
192
+ - name: Validate before publishing
193
+ run: npx docspack doctor --strict
194
+
195
+ - name: Publish to npm
196
+ run: npm publish --access public --provenance
197
+ env:
198
+ NODE_AUTH_TOKEN: \${{ secrets.NPM_TOKEN }}
199
+ `;
200
+ }
@@ -0,0 +1,83 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, relative, resolve, sep } from "node:path";
3
+ import { DocspackError } from "../errors.js";
4
+ import type { InitPlan } from "./plan.js";
5
+
6
+ export type WriteStatus = "created" | "overwritten" | "unchanged" | "skipped";
7
+
8
+ export interface WrittenFile {
9
+ readonly path: string;
10
+ readonly status: WriteStatus;
11
+ }
12
+
13
+ export interface ApplyOptions {
14
+ /** Overwrite files that already exist and differ. */
15
+ readonly force?: boolean;
16
+ /** Report what would happen without touching the disk. */
17
+ readonly dryRun?: boolean;
18
+ }
19
+
20
+ /**
21
+ * Writes a plan. Existing files are left alone unless they are identical or `--force` is set, so
22
+ * re-running `init` in a real project never destroys work.
23
+ */
24
+ export async function applyPlan(
25
+ cwd: string,
26
+ plan: InitPlan,
27
+ options: ApplyOptions = {},
28
+ ): Promise<WrittenFile[]> {
29
+ const root = resolve(cwd, plan.dir);
30
+ const results: WrittenFile[] = [];
31
+
32
+ for (const file of plan.files) {
33
+ const target = resolve(root, file.path);
34
+ const inside = relative(root, target);
35
+ if (inside.length === 0 || inside.startsWith("..") || inside.startsWith(`..${sep}`)) {
36
+ throw new DocspackError(`Refusing to write "${file.path}" outside of ${plan.dir}`);
37
+ }
38
+
39
+ const existing = await read(target);
40
+ if (existing === file.contents) {
41
+ results.push({ path: file.path, status: "unchanged" });
42
+ continue;
43
+ }
44
+ if (existing !== undefined && options.force !== true) {
45
+ results.push({ path: file.path, status: "skipped" });
46
+ continue;
47
+ }
48
+
49
+ if (options.dryRun !== true) {
50
+ await mkdir(dirname(target), { recursive: true });
51
+ await writeFile(target, file.contents, "utf8");
52
+ }
53
+ results.push({ path: file.path, status: existing === undefined ? "created" : "overwritten" });
54
+ }
55
+
56
+ return results;
57
+ }
58
+
59
+ /** Renders a plan as the tree `--dry-run` and the confirmation step print. */
60
+ export function renderTree(plan: InitPlan, written?: readonly WrittenFile[]): string {
61
+ const status = new Map(written?.map((file) => [file.path, file.status]));
62
+ const paths = [...plan.files].map((file) => file.path).sort();
63
+ const lines = [`${plan.dir}/`];
64
+
65
+ paths.forEach((path, index) => {
66
+ const marker = index === paths.length - 1 ? "└──" : "├──";
67
+ const state = status.get(path);
68
+ lines.push(
69
+ `${marker} ${path}${state === undefined || state === "created" ? "" : ` (${state})`}`,
70
+ );
71
+ });
72
+
73
+ return lines.join("\n");
74
+ }
75
+
76
+ async function read(path: string): Promise<string | undefined> {
77
+ try {
78
+ return await readFile(path, "utf8");
79
+ } catch (error) {
80
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
81
+ throw error;
82
+ }
83
+ }
package/src/kinds.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The kinds of documentation problem docspack records.
3
+ *
4
+ * `drift` is Tier A: a name the documentation uses that the library does not declare, which is
5
+ * a fact about two files and needs no model to establish. The other two are Tier B, where a
6
+ * reader exercised judgment and therefore has to show its work.
7
+ *
8
+ * There is deliberately no kind for "this page is confusing". Unfalsifiable claims are
9
+ * infinitely generatable and of no use to a maintainer.
10
+ */
11
+ export const KINDS = ["drift", "incorrect", "missing"] as const;
12
+
13
+ export type FindingKind = (typeof KINDS)[number];
14
+
15
+ export function isFindingKind(value: unknown): value is FindingKind {
16
+ return typeof value === "string" && (KINDS as readonly string[]).includes(value);
17
+ }
@@ -0,0 +1,116 @@
1
+ export interface LlmsTxtLink {
2
+ readonly title: string;
3
+ readonly url: string;
4
+ readonly notes?: string;
5
+ }
6
+
7
+ export interface LlmsTxtSection {
8
+ readonly heading: string;
9
+ readonly links: readonly LlmsTxtLink[];
10
+ }
11
+
12
+ export interface LlmsTxtDocument {
13
+ readonly title?: string;
14
+ readonly summary?: string;
15
+ readonly sections: readonly LlmsTxtSection[];
16
+ /** Every link in document order, across all sections. */
17
+ readonly links: readonly LlmsTxtLink[];
18
+ }
19
+
20
+ const H1 = /^#\s+(.+?)\s*$/;
21
+ const H2 = /^##\s+(.+?)\s*$/;
22
+ const QUOTE = /^>\s?(.*)$/;
23
+ const LIST_LINK = /^\s*[-*+]\s+\[([^\]]*)\]\(([^)]+)\)\s*(?::\s*(.*))?$/;
24
+ const FENCE = /^\s*(?:```|~~~)/;
25
+
26
+ /** Links before the first `##` heading land in this section. */
27
+ const PREAMBLE = "";
28
+
29
+ /**
30
+ * Parses the llms.txt format (https://llmstxt.org). The parser is deliberately tolerant: real
31
+ * files in the wild omit the H1, start at an H2, put the blockquote before the title, and nest
32
+ * link items several levels deep.
33
+ */
34
+ export function parseLlmsTxt(text: string): LlmsTxtDocument {
35
+ const lines = text.replace(/^/, "").replace(/\r\n/g, "\n").split("\n");
36
+
37
+ let title: string | undefined;
38
+ const summaryLines: string[] = [];
39
+ let summaryClosed = false;
40
+ let inFence = false;
41
+ let heading = PREAMBLE;
42
+
43
+ const sections = new Map<string, LlmsTxtLink[]>();
44
+ const links: LlmsTxtLink[] = [];
45
+
46
+ const push = (link: LlmsTxtLink): void => {
47
+ const bucket = sections.get(heading);
48
+ if (bucket === undefined) sections.set(heading, [link]);
49
+ else bucket.push(link);
50
+ links.push(link);
51
+ };
52
+
53
+ for (const line of lines) {
54
+ if (FENCE.test(line)) {
55
+ inFence = !inFence;
56
+ continue;
57
+ }
58
+ if (inFence) continue;
59
+
60
+ const h2 = H2.exec(line);
61
+ if (h2?.[1] !== undefined) {
62
+ heading = h2[1];
63
+ summaryClosed = true;
64
+ if (!sections.has(heading)) sections.set(heading, []);
65
+ continue;
66
+ }
67
+
68
+ const h1 = H1.exec(line);
69
+ if (h1?.[1] !== undefined) {
70
+ title ??= h1[1];
71
+ continue;
72
+ }
73
+
74
+ const quote = QUOTE.exec(line);
75
+ if (quote?.[1] !== undefined) {
76
+ if (!summaryClosed) summaryLines.push(quote[1].trim());
77
+ continue;
78
+ }
79
+ if (summaryLines.length > 0 && line.trim().length === 0) summaryClosed = true;
80
+
81
+ const item = LIST_LINK.exec(line);
82
+ if (item?.[1] !== undefined && item[2] !== undefined) {
83
+ const url = item[2].trim().split(/\s+/)[0];
84
+ if (url === undefined || url.length === 0) continue;
85
+ const notes = item[3]?.trim();
86
+ push({
87
+ title: item[1].trim(),
88
+ url,
89
+ ...(notes === undefined || notes.length === 0 ? {} : { notes }),
90
+ });
91
+ }
92
+ }
93
+
94
+ const summary = summaryLines.join(" ").trim();
95
+
96
+ return {
97
+ ...(title === undefined ? {} : { title }),
98
+ ...(summary.length === 0 ? {} : { summary }),
99
+ sections: [...sections].map(([name, sectionLinks]) => ({ heading: name, links: sectionLinks })),
100
+ links,
101
+ };
102
+ }
103
+
104
+ /** Absolute http(s) links, de-duplicated, in document order. */
105
+ export function fetchableLinks(document: LlmsTxtDocument): readonly LlmsTxtLink[] {
106
+ const seen = new Set<string>();
107
+ const result: LlmsTxtLink[] = [];
108
+ for (const link of document.links) {
109
+ if (!/^https?:\/\//i.test(link.url)) continue;
110
+ const key = link.url.split("#")[0] ?? link.url;
111
+ if (seen.has(key)) continue;
112
+ seen.add(key);
113
+ result.push(link);
114
+ }
115
+ return result;
116
+ }