llms-ink 0.2.0 → 0.4.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 +63 -2
- package/dist/astro.d.ts +7 -3
- package/dist/astro.js +5 -2
- package/dist/files.d.ts +21 -3
- package/dist/files.js +101 -24
- package/dist/index.d.ts +62 -11
- package/dist/index.js +29 -7
- package/dist/middleware.d.ts +27 -8
- package/dist/middleware.js +13 -7
- package/dist/traffic.d.ts +68 -0
- package/dist/traffic.js +221 -0
- package/dist/vite.d.ts +6 -4
- package/dist/vite.js +4 -2
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -52,7 +52,37 @@ Building a big site or a video can take minutes. Bound a call with a signal:
|
|
|
52
52
|
### `ink.check(url, { signal? })` → `Promise<CheckResult>`
|
|
53
53
|
|
|
54
54
|
Checks the site's (or docs section's) published llms.txt against the llmstxt.org format and fetches its first
|
|
55
|
-
25 links: `{ site, url, found, status, title, tokens, sections, links, llmsFull, issues, summary, checkedAt }`.
|
|
55
|
+
25 links: `{ site, url, found, status, title, tokens, sections, links, llmsFull, parsed, issues, summary, checkedAt }`.
|
|
56
|
+
|
|
57
|
+
`parsed` is the file's structure (`null` when nothing was found):
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
{ title, summary, details, sections: [{ name, optional, text, links: [{ title, url, notes, line }] }] }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `ink.checkText(text, { base?, links?, signal? })` → `Promise<CheckResult>`
|
|
64
|
+
|
|
65
|
+
Parses and checks llms.txt text you already have, e.g. before publishing it:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const { parsed, issues } = await ink.checkText(await readFile('public/llms.txt', 'utf8'), {
|
|
69
|
+
base: 'https://example.com/llms.txt', // resolves relative links; reported as url/site
|
|
70
|
+
links: true, // also fetch up to 25 links and look for llms-full.txt
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Without `base`, `site` and `url` are `null`; without `base` and `links`, `llmsFull` is `null` and
|
|
75
|
+
`links.checked` is 0. Up to 2 MB.
|
|
76
|
+
|
|
77
|
+
### `ink.markdown(html, { url?, signal? })` → `Promise<string>`
|
|
78
|
+
|
|
79
|
+
A page's HTML as Markdown, the way llms.ink reads pages: hidden-instruction text (comments, hidden elements,
|
|
80
|
+
off-screen text addressed to AI), scripts, navigation, headers, footers and forms are dropped first. `url` (never
|
|
81
|
+
fetched) resolves relative links. Up to 5 MB; nothing is stored.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const md = await ink.markdown(await readFile('dist/pricing/index.html', 'utf8'), { url: 'https://example.com/pricing/' });
|
|
85
|
+
```
|
|
56
86
|
|
|
57
87
|
### `ink.stream(url, { mode?, fresh?, signal? })` → `AsyncGenerator<StreamEvent>`
|
|
58
88
|
|
|
@@ -84,7 +114,15 @@ After `vite build` (not the dev server or SSR builds), writes `llms.txt` (and `l
|
|
|
84
114
|
`site` into `build.outDir`. llms.ink reads the live site, so the files describe the previous deploy. If llms.ink
|
|
85
115
|
fails, the plugin warns and keeps any existing file (e.g. one from `public/`); `strict: true` fails the build.
|
|
86
116
|
Options: `site`, `full`, `key` (default `LLMS_INK_KEY`), `native` (default `true`; a file llms.ink generated before
|
|
87
|
-
is regenerated, not echoed), `strict`, `timeout` (ms, default 300000), `baseUrl`.
|
|
117
|
+
is regenerated, not echoed), `strict`, `timeout` (ms, default 300000), `baseUrl`, `markdownPages`.
|
|
118
|
+
|
|
119
|
+
`markdownPages: true` also writes a Markdown version next to every HTML page of this build (`about.html` →
|
|
120
|
+
`about.md`, `docs/index.html` → `docs/index.md`), converted with `ink.markdown()` from the local output, so the
|
|
121
|
+
`.md` pages match what you're deploying. Existing `.md` files are kept; `/404`, `/500` and pages over 5 MB are
|
|
122
|
+
skipped; a rate limit is waited out (`Retry-After`). Failures warn and the build goes on, unless `strict`. Pass
|
|
123
|
+
`{ concurrency, exclude }` (default 4 at a time; `exclude` is a RegExp on the page path) to tune it. Large sites:
|
|
124
|
+
use an API key (60 conversions a minute anonymous per IP, the key's own limit otherwise). They're static files:
|
|
125
|
+
`/about.md` works as is; for `Accept: text/markdown` on the page URL, add a rewrite to `<page>.md` on your host.
|
|
88
126
|
|
|
89
127
|
### Astro: `llms-ink/astro`
|
|
90
128
|
|
|
@@ -117,6 +155,29 @@ Everything else, and llms.ink's own fetcher, gets `undefined` (Express: `next()`
|
|
|
117
155
|
`LLMS_INK_KEY`; recommended on shared hosting IPs), `markdown`, `baseUrl`. Don't also ship a static `llms.txt`:
|
|
118
156
|
most hosts serve it before the middleware runs.
|
|
119
157
|
|
|
158
|
+
#### AI traffic
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const llms = llmsInkHandler({ site: 'https://example.com', traffic: { key: env.LLMS_INK_KEY } });
|
|
162
|
+
const response = (await llms(request, ctx)) ?? (await app(request)); // Workers: ctx; Next.js proxy: the event
|
|
163
|
+
|
|
164
|
+
expressApp.use(llmsInkExpress({ site: 'https://example.com', traffic: { key: process.env.LLMS_INK_KEY } }));
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
With `traffic: { key, site?, flushEvery? }` the middleware also counts requests from AI agents (`AI_AGENTS`):
|
|
168
|
+
user fetches (`ChatGPT-User`, `Claude-User`, `Perplexity-User`…, someone asked an assistant) and crawlers (`GPTBot`,
|
|
169
|
+
`OAI-SearchBot`, `ClaudeBot`, `PerplexityBot`, `Googlebot`, `bingbot`…). It counts every request it sees, served or
|
|
170
|
+
not, per path and agent in memory, and every `flushEvery` seconds (default 60, at least 10) sends the day's totals to
|
|
171
|
+
`POST /api/v0/traffic`, at most 1000 rows per request. Only `{ site, period, rows: [{ path, agent, kind, count }] }`
|
|
172
|
+
leaves your server: no IPs, user agents, query strings or cookies; browsers aren't counted. Sends never block a
|
|
173
|
+
response: with `ctx` they go to `ctx.waitUntil`, without one (Node, Bun, Deno) an unref'd timer sends. Failed sends
|
|
174
|
+
keep their counts and retry with backoff (honoring `Retry-After`); a `401` or `403 site_claimed` stops counting with
|
|
175
|
+
one warning. `site` defaults to the middleware's hostname; the first key to send for a site owns it. Counts not yet
|
|
176
|
+
sent are lost when the process stops: call `await llms.flush()` on shutdown. Read them at
|
|
177
|
+
[llms.ink/traffic](https://llms.ink/traffic/) or `GET /api/v0/traffic?site=&days=` with the same key.
|
|
178
|
+
`classifyAgent(userAgent)` and `trafficCounter({ key, site })` (`record(request, ctx?)`, `flush()`) are exported for
|
|
179
|
+
other servers.
|
|
180
|
+
|
|
120
181
|
## Errors
|
|
121
182
|
|
|
122
183
|
Every failure is an `LlmsInkError` with `status`, `code`, `title`, `detail`, `errors` (per-parameter issues of
|
package/dist/astro.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Astro integration: writes llms.txt (and llms-full.txt) from llms.ink into the build output
|
|
2
|
+
* Astro integration: writes llms.txt (and llms-full.txt) from llms.ink into the build output, and with
|
|
3
|
+
* `markdownPages: true` a Markdown version next to every built HTML page.
|
|
3
4
|
*
|
|
4
5
|
* @example
|
|
5
6
|
* ```ts
|
|
@@ -13,7 +14,8 @@
|
|
|
13
14
|
* @module
|
|
14
15
|
*/
|
|
15
16
|
import type { AstroIntegration } from 'astro';
|
|
16
|
-
import { type LlmsFilesOptions } from './files.ts';
|
|
17
|
+
import { type LlmsFilesOptions, type MarkdownPagesOptions } from './files.ts';
|
|
18
|
+
export type { MarkdownPagesOptions };
|
|
17
19
|
/** {@link LlmsFilesOptions} with `site` optional: it defaults to Astro's `site` (plus `base`). */
|
|
18
20
|
export type LlmsInkAstroOptions = Omit<LlmsFilesOptions, 'site'> & {
|
|
19
21
|
site?: string;
|
|
@@ -22,7 +24,9 @@ export type LlmsInkAstroOptions = Omit<LlmsFilesOptions, 'site'> & {
|
|
|
22
24
|
* After `astro build`, fetches llms.txt — and llms-full.txt with `full: true` — for `site` (default: the
|
|
23
25
|
* `site` and `base` of the Astro config) and writes them into the static output directory. llms.ink reads the
|
|
24
26
|
* live site, so the files describe the previous deploy. When llms.ink can't be reached it warns and keeps any
|
|
25
|
-
* existing file (e.g. one copied from `public/`); `strict: true` fails the build instead.
|
|
27
|
+
* existing file (e.g. one copied from `public/`); `strict: true` fails the build instead. `markdownPages: true`
|
|
28
|
+
* converts each prerendered `.html` page of this build through `POST /api/v0/markdown` and writes `about.md` /
|
|
29
|
+
* `docs/index.md` next to it; page URLs come from `site`.
|
|
26
30
|
*/
|
|
27
31
|
export declare function llmsInk(options?: LlmsInkAstroOptions): AstroIntegration;
|
|
28
32
|
export default llmsInk;
|
package/dist/astro.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Astro integration: writes llms.txt (and llms-full.txt) from llms.ink into the build output
|
|
2
|
+
* Astro integration: writes llms.txt (and llms-full.txt) from llms.ink into the build output, and with
|
|
3
|
+
* `markdownPages: true` a Markdown version next to every built HTML page.
|
|
3
4
|
*
|
|
4
5
|
* @example
|
|
5
6
|
* ```ts
|
|
@@ -18,7 +19,9 @@ import { llmsFilesWriter } from "./files.js";
|
|
|
18
19
|
* After `astro build`, fetches llms.txt — and llms-full.txt with `full: true` — for `site` (default: the
|
|
19
20
|
* `site` and `base` of the Astro config) and writes them into the static output directory. llms.ink reads the
|
|
20
21
|
* live site, so the files describe the previous deploy. When llms.ink can't be reached it warns and keeps any
|
|
21
|
-
* existing file (e.g. one copied from `public/`); `strict: true` fails the build instead.
|
|
22
|
+
* existing file (e.g. one copied from `public/`); `strict: true` fails the build instead. `markdownPages: true`
|
|
23
|
+
* converts each prerendered `.html` page of this build through `POST /api/v0/markdown` and writes `about.md` /
|
|
24
|
+
* `docs/index.md` next to it; page URLs come from `site`.
|
|
22
25
|
*/
|
|
23
26
|
export function llmsInk(options = {}) {
|
|
24
27
|
const settings = { ...options, site: options.site ?? '' };
|
package/dist/files.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared by the Vite plugin and the Astro integration: fetch llms.txt (and llms-full.txt) for a site and write
|
|
3
|
-
* them into a build's output directory.
|
|
3
|
+
* them into a build's output directory; with `markdownPages`, write a `.md` version next to every built HTML page.
|
|
4
4
|
*
|
|
5
5
|
* @module
|
|
6
6
|
*/
|
|
7
|
+
/** `markdownPages` settings beyond `true`. */
|
|
8
|
+
export interface MarkdownPagesOptions {
|
|
9
|
+
/** Pages converted at once. Default 4. */
|
|
10
|
+
concurrency?: number;
|
|
11
|
+
/** Pages to skip, matched against the page path (`/docs/`, `/about`). Default: `/404` and `/500` pages. */
|
|
12
|
+
exclude?: RegExp;
|
|
13
|
+
}
|
|
7
14
|
/** Options for the Vite plugin and the Astro integration. */
|
|
8
15
|
export interface LlmsFilesOptions {
|
|
9
16
|
/** The deployed site to describe, e.g. `https://example.com`. llms.ink reads the live site. */
|
|
@@ -18,7 +25,14 @@ export interface LlmsFilesOptions {
|
|
|
18
25
|
* generates (`native=0`).
|
|
19
26
|
*/
|
|
20
27
|
native?: boolean;
|
|
21
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Write a Markdown version of every HTML page of this build next to it (`about.html` → `about.md`,
|
|
30
|
+
* `docs/index.html` → `docs/index.md`), converted by llms.ink from the local output (`POST /api/v0/markdown`):
|
|
31
|
+
* chrome and hidden-instruction text dropped, relative links resolved against `site`. Existing `.md` files are
|
|
32
|
+
* never overwritten; pages over 5 MB are skipped. Default `false`.
|
|
33
|
+
*/
|
|
34
|
+
markdownPages?: boolean | MarkdownPagesOptions;
|
|
35
|
+
/** Fail the build when a file can't be fetched or a page can't be converted. Default `false`: warn and go on. */
|
|
22
36
|
strict?: boolean;
|
|
23
37
|
/** Milliseconds to wait for llms.ink per file. Default 300000 (building a big site can take minutes). */
|
|
24
38
|
timeout?: number;
|
|
@@ -30,8 +44,12 @@ export interface Log {
|
|
|
30
44
|
info(message: string): void;
|
|
31
45
|
warn(message: string): void;
|
|
32
46
|
}
|
|
47
|
+
/** The endpoint's body limit. */
|
|
48
|
+
export declare const MAX_PAGE_BYTES = 5000000;
|
|
33
49
|
/**
|
|
34
50
|
* A writer that fetches the files once and writes them into `dir` on every call (watch builds call it again;
|
|
35
|
-
* a failed fetch is retried on the next call).
|
|
51
|
+
* a failed fetch is retried on the next call). Markdown pages are converted on every call: they describe this build.
|
|
36
52
|
*/
|
|
37
53
|
export declare function llmsFilesWriter(options: LlmsFilesOptions): (dir: string, log: Log) => Promise<void>;
|
|
54
|
+
/** `docs/index.html` → `/docs/`, `about.html` → `/about`, `index.html` → `/`. */
|
|
55
|
+
export declare function pagePath(file: string): string;
|
package/dist/files.js
CHANGED
|
@@ -1,44 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared by the Vite plugin and the Astro integration: fetch llms.txt (and llms-full.txt) for a site and write
|
|
3
|
-
* them into a build's output directory.
|
|
3
|
+
* them into a build's output directory; with `markdownPages`, write a `.md` version next to every built HTML page.
|
|
4
4
|
*
|
|
5
5
|
* @module
|
|
6
6
|
*/
|
|
7
|
-
import { access, mkdir, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import { setTimeout as wait } from 'node:timers/promises';
|
|
10
|
+
import { LlmsInk, LlmsInkError } from "./index.js";
|
|
10
11
|
const FILES = [['llms.txt', 'llms'], ['llms-full.txt', 'full']];
|
|
12
|
+
/** The endpoint's body limit. */
|
|
13
|
+
export const MAX_PAGE_BYTES = 5_000_000;
|
|
14
|
+
const PAGE_TIMEOUT = 60_000;
|
|
15
|
+
/** Tries per page when llms.ink says to slow down (429 with Retry-After). */
|
|
16
|
+
const RATE_TRIES = 4;
|
|
17
|
+
const DEFAULT_EXCLUDE = /^\/(404|500)(\.html|\/)?$/;
|
|
18
|
+
const reason = (error) => (error instanceof Error ? error.message : String(error));
|
|
11
19
|
/**
|
|
12
20
|
* A writer that fetches the files once and writes them into `dir` on every call (watch builds call it again;
|
|
13
|
-
* a failed fetch is retried on the next call).
|
|
21
|
+
* a failed fetch is retried on the next call). Markdown pages are converted on every call: they describe this build.
|
|
14
22
|
*/
|
|
15
23
|
export function llmsFilesWriter(options) {
|
|
16
24
|
let fetched;
|
|
17
25
|
return async (dir, log) => {
|
|
18
26
|
const { site, strict = false } = options;
|
|
19
|
-
if (!site) {
|
|
20
|
-
const message = 'No `site` set: llms.txt not written.';
|
|
21
|
-
if (strict)
|
|
22
|
-
throw new Error(message);
|
|
23
|
-
return log.warn(message);
|
|
24
|
-
}
|
|
25
|
-
fetched ??= fetchFiles(options);
|
|
26
|
-
const results = await fetched;
|
|
27
27
|
const failures = [];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
if (!site)
|
|
29
|
+
failures.push('No `site` set: llms.txt not written.');
|
|
30
|
+
else {
|
|
31
|
+
fetched ??= fetchFiles(options);
|
|
32
|
+
for (const result of await fetched) {
|
|
33
|
+
const path = join(dir, result.name);
|
|
34
|
+
if ('text' in result) {
|
|
35
|
+
await mkdir(dir, { recursive: true });
|
|
36
|
+
await writeFile(path, result.text);
|
|
37
|
+
log.info(`Wrote ${result.name} for ${site} (${result.source}, ~${result.tokens} tokens).`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const kept = await access(path).then(() => 'kept the existing file', () => 'none written');
|
|
41
|
+
failures.push(`Couldn't get ${result.name} for ${site}: ${reason(result.error)} (${kept}).`);
|
|
35
42
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
failures.push(`Couldn't get ${result.name} for ${site}: ${reason} (${kept}).`);
|
|
43
|
+
if (failures.length)
|
|
44
|
+
fetched = undefined;
|
|
39
45
|
}
|
|
40
|
-
if (
|
|
41
|
-
|
|
46
|
+
if (options.markdownPages)
|
|
47
|
+
failures.push(...(await writeMarkdownPages(dir, options, log)));
|
|
42
48
|
if (strict && failures.length)
|
|
43
49
|
throw new Error(failures.join(' '));
|
|
44
50
|
for (const message of failures)
|
|
@@ -50,3 +56,74 @@ async function fetchFiles({ site, full, key, native = true, timeout = 300_000, b
|
|
|
50
56
|
const files = full ? FILES : FILES.slice(0, 1);
|
|
51
57
|
return Promise.all(files.map(([name, mode]) => ink.llms(site, { mode, native, signal: AbortSignal.timeout(timeout) }).then(({ text, tokens, source }) => ({ name, text, tokens, source }), (error) => ({ name, error }))));
|
|
52
58
|
}
|
|
59
|
+
/** Every `.html` file under `dir`, as paths relative to it with `/` separators, sorted. Symlinks aren't followed. */
|
|
60
|
+
async function htmlFiles(dir, prefix = '') {
|
|
61
|
+
const entries = await readdir(join(dir, prefix), { withFileTypes: true }).catch(() => []);
|
|
62
|
+
const nested = await Promise.all(entries.map((e) => {
|
|
63
|
+
const path = prefix ? `${prefix}/${e.name}` : e.name;
|
|
64
|
+
if (e.isDirectory())
|
|
65
|
+
return htmlFiles(dir, path);
|
|
66
|
+
return e.isFile() && e.name.endsWith('.html') ? [path] : [];
|
|
67
|
+
}));
|
|
68
|
+
return nested.flat().sort();
|
|
69
|
+
}
|
|
70
|
+
/** `docs/index.html` → `/docs/`, `about.html` → `/about`, `index.html` → `/`. */
|
|
71
|
+
export function pagePath(file) {
|
|
72
|
+
const path = `/${file.replace(/\.html$/, '')}`;
|
|
73
|
+
return path.endsWith('/index') ? path.slice(0, -'index'.length) : path;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Converts each HTML page of `dir` through llms.ink and writes `<page>.md` next to it. Returns the failure messages
|
|
77
|
+
* (the caller warns or, with `strict`, fails the build).
|
|
78
|
+
*/
|
|
79
|
+
async function writeMarkdownPages(dir, options, log) {
|
|
80
|
+
const settings = typeof options.markdownPages === 'object' ? options.markdownPages : {};
|
|
81
|
+
const exclude = settings.exclude ?? DEFAULT_EXCLUDE;
|
|
82
|
+
const concurrency = Math.max(1, Math.floor(settings.concurrency ?? 4));
|
|
83
|
+
const ink = new LlmsInk({ key: options.key, baseUrl: options.baseUrl });
|
|
84
|
+
const site = options.site ? new URL(options.site.endsWith('/') ? options.site : `${options.site}/`) : null;
|
|
85
|
+
const pages = (await htmlFiles(dir)).filter((file) => !exclude.test(pagePath(file)));
|
|
86
|
+
const failures = [];
|
|
87
|
+
let written = 0;
|
|
88
|
+
let skipped = 0;
|
|
89
|
+
const convert = async (file) => {
|
|
90
|
+
const target = join(dir, file.replace(/\.html$/, '.md'));
|
|
91
|
+
const md = file.replace(/\.html$/, '.md');
|
|
92
|
+
if (await access(target).then(() => true, () => false)) {
|
|
93
|
+
skipped++;
|
|
94
|
+
return log.info(`Kept ${md}: it already exists.`);
|
|
95
|
+
}
|
|
96
|
+
const source = join(dir, file);
|
|
97
|
+
if ((await stat(source)).size > MAX_PAGE_BYTES) {
|
|
98
|
+
skipped++;
|
|
99
|
+
return log.warn(`Skipped ${file}: over ${MAX_PAGE_BYTES.toLocaleString('en-US')} bytes.`);
|
|
100
|
+
}
|
|
101
|
+
const html = await readFile(source, 'utf8');
|
|
102
|
+
// Page URL under `site` (its path included: Astro's `base`).
|
|
103
|
+
const url = site ? new URL(pagePath(file).slice(1), site).href : undefined;
|
|
104
|
+
for (let attempt = 1;; attempt++) {
|
|
105
|
+
try {
|
|
106
|
+
const text = await ink.markdown(html, { url, signal: AbortSignal.timeout(PAGE_TIMEOUT) });
|
|
107
|
+
await writeFile(target, text);
|
|
108
|
+
written++;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const limited = error instanceof LlmsInkError && error.status === 429 && error.code === 'rate_limited';
|
|
113
|
+
if (limited && attempt < RATE_TRIES) {
|
|
114
|
+
await wait(Math.min(error.retryAfter ?? 10, 60) * 1000);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
failures.push(`Couldn't convert ${file} to Markdown: ${reason(error).replace(/\.$/, '')}.`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
let next = 0;
|
|
123
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, pages.length) }, async () => {
|
|
124
|
+
while (next < pages.length)
|
|
125
|
+
await convert(pages[next++]);
|
|
126
|
+
}));
|
|
127
|
+
log.info(`Wrote ${written} Markdown page${written === 1 ? '' : 's'}${skipped ? ` (${skipped} skipped)` : ''}${failures.length ? `; ${failures.length} failed` : ''}.`);
|
|
128
|
+
return failures;
|
|
129
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
*
|
|
14
14
|
* @module
|
|
15
15
|
*/
|
|
16
|
-
export declare const VERSION = "0.
|
|
16
|
+
export declare const VERSION = "0.4.0";
|
|
17
17
|
export declare const DEFAULT_BASE_URL = "https://llms.ink";
|
|
18
18
|
/** `llms` for the index (llms.txt), `full` for llms-full.txt. */
|
|
19
19
|
export type Mode = 'llms' | 'full';
|
|
20
20
|
/** What the link is. */
|
|
21
|
-
export type Kind = 'site' | 'section' | 'page' | 'repo' | 'document' | 'video';
|
|
21
|
+
export type Kind = 'site' | 'section' | 'page' | 'repo' | 'document' | 'video' | 'channel';
|
|
22
22
|
/** `native` = published by the site; `generated` = made by llms.ink. */
|
|
23
23
|
export type Source = 'native' | 'generated';
|
|
24
24
|
/** A transcript line; `start` and `end` are seconds into the source video. */
|
|
@@ -72,12 +72,40 @@ export interface CheckIssue {
|
|
|
72
72
|
line: number | null;
|
|
73
73
|
message: string;
|
|
74
74
|
}
|
|
75
|
-
/**
|
|
76
|
-
export interface
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
/** The llms.txt that was checked (or the last place looked when none was found). */
|
|
75
|
+
/** One link of a section: `- [title](url): notes`. */
|
|
76
|
+
export interface ParsedLink {
|
|
77
|
+
title: string;
|
|
78
|
+
/** Resolved against `base` / the fetched URL when possible, else as written. */
|
|
80
79
|
url: string;
|
|
80
|
+
/** Text after `:`, trimmed. */
|
|
81
|
+
notes: string | null;
|
|
82
|
+
line: number;
|
|
83
|
+
}
|
|
84
|
+
/** An H2 section of llms.txt. */
|
|
85
|
+
export interface ParsedSection {
|
|
86
|
+
name: string;
|
|
87
|
+
/** The section is named "Optional" (case-insensitive). */
|
|
88
|
+
optional: boolean;
|
|
89
|
+
/** Non-link content of the section. */
|
|
90
|
+
text: string | null;
|
|
91
|
+
links: ParsedLink[];
|
|
92
|
+
}
|
|
93
|
+
/** The structure of an llms.txt file. */
|
|
94
|
+
export interface ParsedLlms {
|
|
95
|
+
/** The H1. */
|
|
96
|
+
title: string | null;
|
|
97
|
+
/** The blockquote, without `> `. */
|
|
98
|
+
summary: string | null;
|
|
99
|
+
/** Markdown between the summary and the first H2. */
|
|
100
|
+
details: string | null;
|
|
101
|
+
sections: ParsedSection[];
|
|
102
|
+
}
|
|
103
|
+
/** `GET` / `POST /api/v0/check`: an llms.txt checked against the format and its links. */
|
|
104
|
+
export interface CheckResult {
|
|
105
|
+
/** The site's origin; null for {@link LlmsInk.checkText} without `base`. */
|
|
106
|
+
site: string | null;
|
|
107
|
+
/** The llms.txt that was checked (or the last place looked when none was found); null for text without `base`. */
|
|
108
|
+
url: string | null;
|
|
81
109
|
found: boolean;
|
|
82
110
|
/** HTTP status of the llms.txt request. */
|
|
83
111
|
status: number;
|
|
@@ -87,7 +115,7 @@ export interface CheckResult {
|
|
|
87
115
|
sections: number;
|
|
88
116
|
links: {
|
|
89
117
|
total: number;
|
|
90
|
-
/** Links fetched this run (the first 25). */
|
|
118
|
+
/** Links fetched this run (the first 25); 0 when links weren't checked. */
|
|
91
119
|
checked: number;
|
|
92
120
|
/** `status` is 0 when the link didn't answer in time. */
|
|
93
121
|
broken: Array<{
|
|
@@ -95,8 +123,10 @@ export interface CheckResult {
|
|
|
95
123
|
status: number;
|
|
96
124
|
}>;
|
|
97
125
|
};
|
|
98
|
-
/** Whether llms-full.txt is published next to it. */
|
|
99
|
-
llmsFull: boolean;
|
|
126
|
+
/** Whether llms-full.txt is published next to it; null when not checked. */
|
|
127
|
+
llmsFull: boolean | null;
|
|
128
|
+
/** The parsed file; null when nothing was found. */
|
|
129
|
+
parsed: ParsedLlms | null;
|
|
100
130
|
/** Errors first, then warnings and notes, in line order. */
|
|
101
131
|
issues: CheckIssue[];
|
|
102
132
|
summary: {
|
|
@@ -107,7 +137,7 @@ export interface CheckResult {
|
|
|
107
137
|
checkedAt: string;
|
|
108
138
|
}
|
|
109
139
|
/** Every error code the API answers with (https://llms.ink/docs/#errors). */
|
|
110
|
-
export type ErrorCode = 'invalid_request' | 'invalid_url' | 'invalid_key' | 'not_found' | 'unknown_version' | 'method_not_allowed' | 'payload_too_large' | 'unprocessable' | 'rate_limited' | 'quota_exceeded' | 'internal' | 'upstream_error' | 'unavailable' | 'timeout' | 'loop_detected';
|
|
140
|
+
export type ErrorCode = 'invalid_request' | 'invalid_url' | 'invalid_key' | 'key_required' | 'site_claimed' | 'not_found' | 'unknown_version' | 'method_not_allowed' | 'payload_too_large' | 'too_large' | 'unsupported_media_type' | 'unprocessable' | 'rate_limited' | 'quota_exceeded' | 'internal' | 'upstream_error' | 'unavailable' | 'timeout' | 'loop_detected';
|
|
111
141
|
/** `invalid_request` only: one invalid parameter. */
|
|
112
142
|
export interface Issue {
|
|
113
143
|
path: string;
|
|
@@ -206,6 +236,20 @@ export interface LlmsOptions {
|
|
|
206
236
|
export interface CheckOptions {
|
|
207
237
|
signal?: AbortSignal;
|
|
208
238
|
}
|
|
239
|
+
/** Options for {@link LlmsInk.checkText}. */
|
|
240
|
+
export interface CheckTextOptions {
|
|
241
|
+
/** Public URL the text lives at: resolves relative links and is reported as `url` / `site`. */
|
|
242
|
+
base?: string;
|
|
243
|
+
/** Also check up to 25 linked URLs (and llms-full.txt next to `base`). Defaults to `false`. */
|
|
244
|
+
links?: boolean;
|
|
245
|
+
signal?: AbortSignal;
|
|
246
|
+
}
|
|
247
|
+
/** Options for {@link LlmsInk.markdown}. */
|
|
248
|
+
export interface MarkdownOptions {
|
|
249
|
+
/** Where the page lives (never fetched): relative links in the Markdown resolve against it. */
|
|
250
|
+
url?: string;
|
|
251
|
+
signal?: AbortSignal;
|
|
252
|
+
}
|
|
209
253
|
/**
|
|
210
254
|
* A client for https://llms.ink.
|
|
211
255
|
*
|
|
@@ -226,6 +270,13 @@ export declare class LlmsInk {
|
|
|
226
270
|
llms(url: string, options?: LlmsOptions): Promise<Result>;
|
|
227
271
|
/** Checks a site's published llms.txt against the llmstxt.org format and that its links answer. */
|
|
228
272
|
check(url: string, options?: CheckOptions): Promise<CheckResult>;
|
|
273
|
+
/** Parses and checks llms.txt text you already have (`POST /api/v0/check`); nothing is published. */
|
|
274
|
+
checkText(text: string, options?: CheckTextOptions): Promise<CheckResult>;
|
|
275
|
+
/**
|
|
276
|
+
* A page's HTML as Markdown (`POST /api/v0/markdown`, up to 5 MB): hidden-instruction text, scripts, navigation,
|
|
277
|
+
* headers and footers dropped, the way llms.ink reads pages. Nothing is fetched or stored.
|
|
278
|
+
*/
|
|
279
|
+
markdown(html: string, options?: MarkdownOptions): Promise<string>;
|
|
229
280
|
/**
|
|
230
281
|
* Like {@link LlmsInk.llms}, as the build happens: yields `progress` and `partial` events, then one
|
|
231
282
|
* `result`. Errors — including a terminal `error` event — are thrown as {@link LlmsInkError}s.
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* @module
|
|
15
15
|
*/
|
|
16
16
|
import { env } from "./env.js";
|
|
17
|
-
export const VERSION = '0.
|
|
17
|
+
export const VERSION = '0.4.0';
|
|
18
18
|
export const DEFAULT_BASE_URL = 'https://llms.ink';
|
|
19
19
|
/**
|
|
20
20
|
* An API error, or llms.ink couldn't be reached (then `status` and `code` are undefined).
|
|
@@ -99,21 +99,40 @@ export class LlmsInk {
|
|
|
99
99
|
* any public link.
|
|
100
100
|
*/
|
|
101
101
|
async llms(url, options = {}) {
|
|
102
|
-
const res = await this.#
|
|
102
|
+
const res = await this.#request('/api/v0/llms', llmsParams(url, options), 'application/json', options.signal);
|
|
103
103
|
return (await this.#json(res));
|
|
104
104
|
}
|
|
105
105
|
/** Checks a site's published llms.txt against the llmstxt.org format and that its links answer. */
|
|
106
106
|
async check(url, options = {}) {
|
|
107
|
-
const res = await this.#
|
|
107
|
+
const res = await this.#request('/api/v0/check', { url }, 'application/json', options.signal);
|
|
108
108
|
return (await this.#json(res));
|
|
109
109
|
}
|
|
110
|
+
/** Parses and checks llms.txt text you already have (`POST /api/v0/check`); nothing is published. */
|
|
111
|
+
async checkText(text, options = {}) {
|
|
112
|
+
const params = {};
|
|
113
|
+
if (options.base)
|
|
114
|
+
params.base = options.base;
|
|
115
|
+
if (options.links)
|
|
116
|
+
params.links = '1';
|
|
117
|
+
const res = await this.#request('/api/v0/check', params, 'application/json', options.signal, text);
|
|
118
|
+
return (await this.#json(res));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A page's HTML as Markdown (`POST /api/v0/markdown`, up to 5 MB): hidden-instruction text, scripts, navigation,
|
|
122
|
+
* headers and footers dropped, the way llms.ink reads pages. Nothing is fetched or stored.
|
|
123
|
+
*/
|
|
124
|
+
async markdown(html, options = {}) {
|
|
125
|
+
const params = options.url ? { url: options.url } : {};
|
|
126
|
+
const res = await this.#request('/api/v0/markdown', params, 'text/markdown', options.signal, html, 'text/html; charset=utf-8');
|
|
127
|
+
return res.text();
|
|
128
|
+
}
|
|
110
129
|
/**
|
|
111
130
|
* Like {@link LlmsInk.llms}, as the build happens: yields `progress` and `partial` events, then one
|
|
112
131
|
* `result`. Errors — including a terminal `error` event — are thrown as {@link LlmsInkError}s.
|
|
113
132
|
* Breaking out of the loop cancels the request.
|
|
114
133
|
*/
|
|
115
134
|
async *stream(url, options = {}) {
|
|
116
|
-
const res = await this.#
|
|
135
|
+
const res = await this.#request('/api/v0/llms', llmsParams(url, options), 'application/x-ndjson', options.signal);
|
|
117
136
|
if (!res.headers.get('content-type')?.includes('ndjson')) {
|
|
118
137
|
yield { type: 'result', result: (await this.#json(res)) };
|
|
119
138
|
return;
|
|
@@ -173,16 +192,19 @@ export class LlmsInk {
|
|
|
173
192
|
const known = event.type === 'progress' || event.type === 'partial' || event.type === 'result';
|
|
174
193
|
return known ? event : undefined;
|
|
175
194
|
}
|
|
176
|
-
async #
|
|
195
|
+
async #request(path, params, accept, signal, body, type = 'text/markdown; charset=utf-8') {
|
|
177
196
|
const headers = { accept };
|
|
197
|
+
if (body !== undefined)
|
|
198
|
+
headers['content-type'] = type;
|
|
178
199
|
if (!IS_BROWSER)
|
|
179
200
|
headers['user-agent'] = `llms-ink-js/${VERSION}`;
|
|
180
201
|
if (this.key)
|
|
181
202
|
headers.authorization = `Bearer ${this.key}`;
|
|
182
|
-
const
|
|
203
|
+
const query = new URLSearchParams(params).toString();
|
|
204
|
+
const target = `${this.baseUrl}${path}${query ? `?${query}` : ''}`;
|
|
183
205
|
let res;
|
|
184
206
|
try {
|
|
185
|
-
res = await this.#fetch(target, { headers, signal });
|
|
207
|
+
res = await this.#fetch(target, body === undefined ? { headers, signal } : { method: 'POST', headers, body, signal });
|
|
186
208
|
}
|
|
187
209
|
catch (err) {
|
|
188
210
|
if (isAbort(err))
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Serve llms.txt, llms-full.txt and (optionally) Markdown versions of pages from llms.ink, inside your own app
|
|
3
|
-
* A fetch-API handler for Cloudflare Workers, Next.js middleware, Deno, Bun and
|
|
4
|
-
* adapter.
|
|
2
|
+
* Serve llms.txt, llms-full.txt and (optionally) Markdown versions of pages from llms.ink, inside your own app, and
|
|
3
|
+
* (optionally) count AI-agent traffic. A fetch-API handler for Cloudflare Workers, Next.js middleware, Deno, Bun and
|
|
4
|
+
* Hono, plus an Express/Connect adapter.
|
|
5
5
|
*
|
|
6
6
|
* @example
|
|
7
7
|
* ```ts
|
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
*
|
|
10
10
|
* const llms = llmsInkHandler({ site: 'https://example.com', markdown: true });
|
|
11
11
|
* export default {
|
|
12
|
-
* async fetch(request: Request): Promise<Response> {
|
|
13
|
-
* return (await llms(request)) ?? fetch(request); // undefined: not ours, the app answers
|
|
12
|
+
* async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response> {
|
|
13
|
+
* return (await llms(request, ctx)) ?? fetch(request); // undefined: not ours, the app answers
|
|
14
14
|
* },
|
|
15
15
|
* };
|
|
16
16
|
* ```
|
|
17
17
|
*
|
|
18
18
|
* @module
|
|
19
19
|
*/
|
|
20
|
+
import { type TrafficOptions, type WaitUntil } from './traffic.ts';
|
|
21
|
+
export { AI_AGENTS, classifyAgent, trafficCounter, type AgentKind, type AiAgent, type TrafficBatch, type TrafficCounter, type TrafficOptions, type TrafficRow, type WaitUntil } from './traffic.ts';
|
|
20
22
|
/** Options for {@link llmsInkHandler} and {@link llmsInkExpress}. */
|
|
21
23
|
export interface LlmsInkHandlerOptions {
|
|
22
24
|
/** The deployed site to describe, e.g. `https://example.com`. */
|
|
@@ -33,9 +35,22 @@ export interface LlmsInkHandlerOptions {
|
|
|
33
35
|
markdown?: boolean;
|
|
34
36
|
/** Defaults to the `LLMS_INK_API` environment variable, else https://llms.ink. */
|
|
35
37
|
baseUrl?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Count requests from AI agents (ChatGPT-User, Claude-User, GPTBot, ClaudeBot… see `AI_AGENTS`) per path and send
|
|
40
|
+
* daily aggregates to llms.ink every `flushEvery` seconds; read them at https://llms.ink/traffic/. Sends only
|
|
41
|
+
* `{ path, agent, kind, count }`: no IPs, user agents, query strings or cookies. Off unless set.
|
|
42
|
+
*/
|
|
43
|
+
traffic?: TrafficOptions;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A fetch-API handler: a `Response` for the requests it serves, `undefined` for everything else. Pass the
|
|
47
|
+
* platform's `ctx` (Workers' `ExecutionContext`, Next.js's `NextFetchEvent`) so traffic sends outlive the response.
|
|
48
|
+
*/
|
|
49
|
+
export interface LlmsInkHandler {
|
|
50
|
+
(request: Request, ctx?: WaitUntil): Promise<Response | undefined>;
|
|
51
|
+
/** Sends the traffic counted so far (with `traffic`); resolves at once without it. */
|
|
52
|
+
flush(): Promise<void>;
|
|
36
53
|
}
|
|
37
|
-
/** A fetch-API handler: a `Response` for the requests it serves, `undefined` for everything else. */
|
|
38
|
-
export type LlmsInkHandler = (request: Request) => Promise<Response | undefined>;
|
|
39
54
|
/**
|
|
40
55
|
* Answers `GET`/`HEAD` `/llms.txt` and `/llms-full.txt` with llms.ink's generated files for `site`
|
|
41
56
|
* (`native=0`, so a site whose /llms.txt is this handler can't loop), and, with `markdown: true`, Markdown
|
|
@@ -59,6 +74,10 @@ export interface NodeResponse {
|
|
|
59
74
|
end(chunk: Uint8Array): unknown;
|
|
60
75
|
}
|
|
61
76
|
/** Express/Connect middleware: `next()` when the request isn't one {@link llmsInkHandler} serves. */
|
|
62
|
-
export
|
|
77
|
+
export interface NodeMiddleware {
|
|
78
|
+
(req: NodeRequest, res: NodeResponse, next: (err?: unknown) => void): void;
|
|
79
|
+
/** Sends the traffic counted so far (with `traffic`). */
|
|
80
|
+
flush(): Promise<void>;
|
|
81
|
+
}
|
|
63
82
|
/** {@link llmsInkHandler} as Express/Connect middleware: `app.use(llmsInkExpress({ site }))`. */
|
|
64
83
|
export declare function llmsInkExpress(options: LlmsInkHandlerOptions): NodeMiddleware;
|
package/dist/middleware.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Serve llms.txt, llms-full.txt and (optionally) Markdown versions of pages from llms.ink, inside your own app
|
|
3
|
-
* A fetch-API handler for Cloudflare Workers, Next.js middleware, Deno, Bun and
|
|
4
|
-
* adapter.
|
|
2
|
+
* Serve llms.txt, llms-full.txt and (optionally) Markdown versions of pages from llms.ink, inside your own app, and
|
|
3
|
+
* (optionally) count AI-agent traffic. A fetch-API handler for Cloudflare Workers, Next.js middleware, Deno, Bun and
|
|
4
|
+
* Hono, plus an Express/Connect adapter.
|
|
5
5
|
*
|
|
6
6
|
* @example
|
|
7
7
|
* ```ts
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* const llms = llmsInkHandler({ site: 'https://example.com', markdown: true });
|
|
11
11
|
* export default {
|
|
12
|
-
* async fetch(request: Request): Promise<Response> {
|
|
13
|
-
* return (await llms(request)) ?? fetch(request); // undefined: not ours, the app answers
|
|
12
|
+
* async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response> {
|
|
13
|
+
* return (await llms(request, ctx)) ?? fetch(request); // undefined: not ours, the app answers
|
|
14
14
|
* },
|
|
15
15
|
* };
|
|
16
16
|
* ```
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { env } from "./env.js";
|
|
21
21
|
import { DEFAULT_BASE_URL, VERSION } from "./index.js";
|
|
22
|
+
import { trafficCounter } from "./traffic.js";
|
|
23
|
+
export { AI_AGENTS, classifyAgent, trafficCounter } from "./traffic.js";
|
|
22
24
|
const FILES = { '/llms.txt': 'llms', '/llms-full.txt': 'full' };
|
|
23
25
|
/** llms.ink's own fetcher (`compatible; llms.ink/0.1`): answering it from llms.ink would loop. */
|
|
24
26
|
const LLMS_INK_FETCHER = /\bllms\.ink\/\d/;
|
|
@@ -44,6 +46,7 @@ export function llmsInkHandler(options) {
|
|
|
44
46
|
headers.authorization = `Bearer ${key}`;
|
|
45
47
|
const cache = new Map();
|
|
46
48
|
const pending = new Map();
|
|
49
|
+
const traffic = options.traffic ? trafficCounter({ ...options.traffic, site: options.traffic.site ?? site.hostname, baseUrl: base }) : null;
|
|
47
50
|
/** Fetches `upstream`; on failure the last good copy, else llms.ink's error. */
|
|
48
51
|
async function refresh(upstream) {
|
|
49
52
|
const last = cache.get(upstream);
|
|
@@ -85,7 +88,8 @@ export function llmsInkHandler(options) {
|
|
|
85
88
|
// Concurrent requests share one fetch; each gets its own copy of the body.
|
|
86
89
|
return (await loading).clone();
|
|
87
90
|
}
|
|
88
|
-
|
|
91
|
+
const handler = async (request, ctx) => {
|
|
92
|
+
traffic?.record(request, ctx);
|
|
89
93
|
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
90
94
|
return undefined;
|
|
91
95
|
if (LLMS_INK_FETCHER.test(request.headers.get('user-agent') ?? ''))
|
|
@@ -110,11 +114,12 @@ export function llmsInkHandler(options) {
|
|
|
110
114
|
}
|
|
111
115
|
return request.method === 'HEAD' ? new Response(null, response) : response;
|
|
112
116
|
};
|
|
117
|
+
return Object.assign(handler, { flush: () => traffic?.flush() ?? Promise.resolve() });
|
|
113
118
|
}
|
|
114
119
|
/** {@link llmsInkHandler} as Express/Connect middleware: `app.use(llmsInkExpress({ site }))`. */
|
|
115
120
|
export function llmsInkExpress(options) {
|
|
116
121
|
const handle = llmsInkHandler(options);
|
|
117
|
-
|
|
122
|
+
const middleware = (req, res, next) => {
|
|
118
123
|
const headers = new Headers();
|
|
119
124
|
for (const name of ['accept', 'user-agent']) {
|
|
120
125
|
const value = req.headers[name];
|
|
@@ -132,6 +137,7 @@ export function llmsInkExpress(options) {
|
|
|
132
137
|
})
|
|
133
138
|
.catch(next);
|
|
134
139
|
};
|
|
140
|
+
return Object.assign(middleware, { flush: handle.flush });
|
|
135
141
|
}
|
|
136
142
|
function pick(from, names) {
|
|
137
143
|
const to = new Headers();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI traffic: which AI agents fetch which pages of your site, counted in memory and sent to llms.ink as daily
|
|
3
|
+
* aggregates (`POST /api/v0/traffic`). Only requests from the agents in {@link AI_AGENTS} are counted; what leaves
|
|
4
|
+
* your server is `{ site, period, rows: [{ path, agent, kind, count }] }` — no IPs, no user agents, no query
|
|
5
|
+
* strings, no cookies, nothing about people who visit.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
/** `user`: fetched because someone asked an assistant; `crawler`: indexing for search or collecting for training. */
|
|
10
|
+
export type AgentKind = 'user' | 'crawler';
|
|
11
|
+
/** One AI agent: the name it puts in its User-Agent (matched case-insensitively), what kind it is, who runs it. */
|
|
12
|
+
export interface AiAgent {
|
|
13
|
+
agent: string;
|
|
14
|
+
kind: AgentKind;
|
|
15
|
+
operator: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The agents counted, from the operators' published bot lists. Robots-only tokens (`Google-Extended`,
|
|
19
|
+
* `Applebot-Extended`) never appear in a User-Agent, so they aren't here: Googlebot and Applebot do that crawling.
|
|
20
|
+
*/
|
|
21
|
+
export declare const AI_AGENTS: readonly AiAgent[];
|
|
22
|
+
/** The AI agent behind a User-Agent header, or null for browsers, other bots and anything unknown. */
|
|
23
|
+
export declare function classifyAgent(userAgent: string | null | undefined): AiAgent | null;
|
|
24
|
+
/** Options for {@link trafficCounter}, and the middleware's `traffic` option. */
|
|
25
|
+
export interface TrafficOptions {
|
|
26
|
+
/** API key (`lk_…`). The first key to send counts for a site owns it; read them back with the same key. */
|
|
27
|
+
key: string;
|
|
28
|
+
/** Hostname the counts are filed under, e.g. `example.com`. The middleware defaults it to its `site`. */
|
|
29
|
+
site?: string;
|
|
30
|
+
/** Seconds between sends. Default 60, at least 10. */
|
|
31
|
+
flushEvery?: number;
|
|
32
|
+
}
|
|
33
|
+
/** One aggregate: requests from one agent to one path in one UTC day. */
|
|
34
|
+
export interface TrafficRow {
|
|
35
|
+
path: string;
|
|
36
|
+
agent: string;
|
|
37
|
+
kind: AgentKind;
|
|
38
|
+
count: number;
|
|
39
|
+
}
|
|
40
|
+
/** The body of `POST /api/v0/traffic`. */
|
|
41
|
+
export interface TrafficBatch {
|
|
42
|
+
site: string;
|
|
43
|
+
/** UTC day, `YYYY-MM-DD`. */
|
|
44
|
+
period: string;
|
|
45
|
+
rows: TrafficRow[];
|
|
46
|
+
}
|
|
47
|
+
/** Workers' `ExecutionContext`, Next.js's `NextFetchEvent`: keeps a send alive after the response. */
|
|
48
|
+
export interface WaitUntil {
|
|
49
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
50
|
+
}
|
|
51
|
+
/** Counts AI-agent requests and sends them in the background. */
|
|
52
|
+
export interface TrafficCounter {
|
|
53
|
+
/**
|
|
54
|
+
* Counts the request when an AI agent sent it (`GET`/`HEAD` only). When a send is due it starts one and hands it
|
|
55
|
+
* to `ctx.waitUntil`; without `ctx`, a background timer sends. Never throws and never waits.
|
|
56
|
+
*/
|
|
57
|
+
record(request: Request, ctx?: WaitUntil): void;
|
|
58
|
+
/** Sends everything counted so far. Never rejects; failed sends are kept and retried with backoff. */
|
|
59
|
+
flush(): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A counter for one site. Use it directly in servers the middleware doesn't cover:
|
|
63
|
+
* `counter.record(request, ctx)` for each request, `await counter.flush()` before shutting down.
|
|
64
|
+
*/
|
|
65
|
+
export declare function trafficCounter(options: TrafficOptions & {
|
|
66
|
+
site: string;
|
|
67
|
+
baseUrl?: string;
|
|
68
|
+
}): TrafficCounter;
|
package/dist/traffic.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI traffic: which AI agents fetch which pages of your site, counted in memory and sent to llms.ink as daily
|
|
3
|
+
* aggregates (`POST /api/v0/traffic`). Only requests from the agents in {@link AI_AGENTS} are counted; what leaves
|
|
4
|
+
* your server is `{ site, period, rows: [{ path, agent, kind, count }] }` — no IPs, no user agents, no query
|
|
5
|
+
* strings, no cookies, nothing about people who visit.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import { DEFAULT_BASE_URL, VERSION } from "./index.js";
|
|
10
|
+
/**
|
|
11
|
+
* The agents counted, from the operators' published bot lists. Robots-only tokens (`Google-Extended`,
|
|
12
|
+
* `Applebot-Extended`) never appear in a User-Agent, so they aren't here: Googlebot and Applebot do that crawling.
|
|
13
|
+
*/
|
|
14
|
+
export const AI_AGENTS = [
|
|
15
|
+
// Fetches on behalf of someone asking an assistant right now.
|
|
16
|
+
{ agent: 'ChatGPT-User', kind: 'user', operator: 'OpenAI' },
|
|
17
|
+
{ agent: 'Claude-User', kind: 'user', operator: 'Anthropic' },
|
|
18
|
+
{ agent: 'Perplexity-User', kind: 'user', operator: 'Perplexity' },
|
|
19
|
+
{ agent: 'MistralAI-User', kind: 'user', operator: 'Mistral' },
|
|
20
|
+
{ agent: 'DuckAssistBot', kind: 'user', operator: 'DuckDuckGo' },
|
|
21
|
+
{ agent: 'meta-externalfetcher', kind: 'user', operator: 'Meta' },
|
|
22
|
+
{ agent: 'Google-NotebookLM', kind: 'user', operator: 'Google' },
|
|
23
|
+
{ agent: 'Amzn-User', kind: 'user', operator: 'Amazon' },
|
|
24
|
+
// Crawlers: AI search indexes, training collection, and the search engines AI answers draw on.
|
|
25
|
+
{ agent: 'OAI-SearchBot', kind: 'crawler', operator: 'OpenAI' },
|
|
26
|
+
{ agent: 'GPTBot', kind: 'crawler', operator: 'OpenAI' },
|
|
27
|
+
{ agent: 'Claude-SearchBot', kind: 'crawler', operator: 'Anthropic' },
|
|
28
|
+
{ agent: 'ClaudeBot', kind: 'crawler', operator: 'Anthropic' },
|
|
29
|
+
{ agent: 'anthropic-ai', kind: 'crawler', operator: 'Anthropic' },
|
|
30
|
+
{ agent: 'PerplexityBot', kind: 'crawler', operator: 'Perplexity' },
|
|
31
|
+
{ agent: 'Google-CloudVertexBot', kind: 'crawler', operator: 'Google' },
|
|
32
|
+
{ agent: 'Googlebot', kind: 'crawler', operator: 'Google' },
|
|
33
|
+
{ agent: 'bingbot', kind: 'crawler', operator: 'Microsoft' },
|
|
34
|
+
{ agent: 'Applebot', kind: 'crawler', operator: 'Apple' },
|
|
35
|
+
{ agent: 'Amzn-SearchBot', kind: 'crawler', operator: 'Amazon' },
|
|
36
|
+
{ agent: 'Amazonbot', kind: 'crawler', operator: 'Amazon' },
|
|
37
|
+
{ agent: 'meta-externalagent', kind: 'crawler', operator: 'Meta' },
|
|
38
|
+
{ agent: 'Bytespider', kind: 'crawler', operator: 'ByteDance' },
|
|
39
|
+
{ agent: 'CCBot', kind: 'crawler', operator: 'Common Crawl' },
|
|
40
|
+
{ agent: 'cohere-ai', kind: 'crawler', operator: 'Cohere' },
|
|
41
|
+
{ agent: 'YouBot', kind: 'crawler', operator: 'You.com' },
|
|
42
|
+
{ agent: 'DuckDuckBot', kind: 'crawler', operator: 'DuckDuckGo' },
|
|
43
|
+
];
|
|
44
|
+
const BY_TOKEN = new Map(AI_AGENTS.map((a) => [a.agent.toLowerCase(), a]));
|
|
45
|
+
/** One pass over the User-Agent: a known name not glued to a preceding letter, digit or hyphen. */
|
|
46
|
+
const MATCH = new RegExp(`(?:^|[^a-z0-9-])(${AI_AGENTS.map((a) => a.agent.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')).join('|')})`, 'i');
|
|
47
|
+
/** The AI agent behind a User-Agent header, or null for browsers, other bots and anything unknown. */
|
|
48
|
+
export function classifyAgent(userAgent) {
|
|
49
|
+
if (!userAgent)
|
|
50
|
+
return null;
|
|
51
|
+
const match = MATCH.exec(userAgent);
|
|
52
|
+
return match ? BY_TOKEN.get(match[1].toLowerCase()) ?? null : null;
|
|
53
|
+
}
|
|
54
|
+
/** Rows per POST (the API's limit). */
|
|
55
|
+
const ROWS_PER_POST = 1000;
|
|
56
|
+
/** Distinct path/agent/day rows kept in memory; past it, new paths count as `(other)`. */
|
|
57
|
+
const MAX_PENDING = 5000;
|
|
58
|
+
/** The path new rows are folded into once {@link MAX_PENDING} is reached. */
|
|
59
|
+
const OTHER_PATH = '/(other)';
|
|
60
|
+
const MAX_PATH = 512;
|
|
61
|
+
const MAX_BACKOFF_MS = 3_600_000;
|
|
62
|
+
const SEND_TIMEOUT_MS = 10_000;
|
|
63
|
+
/** The path only: no query string or fragment (URL.pathname has neither), capped in length. */
|
|
64
|
+
function cleanPath(url) {
|
|
65
|
+
const path = new URL(url).pathname || '/';
|
|
66
|
+
return path.length > MAX_PATH ? path.slice(0, MAX_PATH) : path;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A counter for one site. Use it directly in servers the middleware doesn't cover:
|
|
70
|
+
* `counter.record(request, ctx)` for each request, `await counter.flush()` before shutting down.
|
|
71
|
+
*/
|
|
72
|
+
export function trafficCounter(options) {
|
|
73
|
+
const key = options.key?.trim();
|
|
74
|
+
if (!key)
|
|
75
|
+
throw new TypeError('traffic needs an llms.ink API key (traffic: { key: "lk_…" }).');
|
|
76
|
+
// `https://Example.com/x` or `example.com` → `example.com`.
|
|
77
|
+
const site = new URL(/^https?:\/\//i.test(options.site) ? options.site : `https://${options.site}`).hostname.toLowerCase();
|
|
78
|
+
const every = Math.max(10, options.flushEvery ?? 60) * 1000;
|
|
79
|
+
const endpoint = `${(options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '')}/api/v0/traffic`;
|
|
80
|
+
const headers = { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'user-agent': `llms-ink-js/${VERSION}` };
|
|
81
|
+
/** `period\tpath\tagent` → row. */
|
|
82
|
+
const pending = new Map();
|
|
83
|
+
let lastSend = Date.now();
|
|
84
|
+
let retryAt = 0;
|
|
85
|
+
let backoff = 0;
|
|
86
|
+
let inflight = null;
|
|
87
|
+
let timer;
|
|
88
|
+
/** Set when llms.ink refuses the key or the site: counting stops, once, with a warning. */
|
|
89
|
+
let stopped = false;
|
|
90
|
+
function add(period, path, agent, kind, count) {
|
|
91
|
+
let id = `${period}\t${path}\t${agent}`;
|
|
92
|
+
let row = pending.get(id);
|
|
93
|
+
if (!row && pending.size >= MAX_PENDING) {
|
|
94
|
+
path = OTHER_PATH;
|
|
95
|
+
id = `${period}\t${path}\t${agent}`;
|
|
96
|
+
row = pending.get(id);
|
|
97
|
+
}
|
|
98
|
+
if (row)
|
|
99
|
+
row.count += count;
|
|
100
|
+
else
|
|
101
|
+
pending.set(id, { period, path, agent, kind, count });
|
|
102
|
+
}
|
|
103
|
+
function stop(message) {
|
|
104
|
+
stopped = true;
|
|
105
|
+
pending.clear();
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
console.warn(`llms-ink traffic: ${message} Counting stopped.`);
|
|
108
|
+
}
|
|
109
|
+
function retryLater(rows, retryAfter) {
|
|
110
|
+
for (const r of rows)
|
|
111
|
+
add(r.period, r.path, r.agent, r.kind, r.count);
|
|
112
|
+
backoff = Math.min(MAX_BACKOFF_MS, Math.max(every, backoff * 2));
|
|
113
|
+
retryAt = Date.now() + Math.max(backoff, (retryAfter ?? 0) * 1000);
|
|
114
|
+
}
|
|
115
|
+
/** One POST; resolves to what to do with its rows. */
|
|
116
|
+
async function send(batch) {
|
|
117
|
+
let res;
|
|
118
|
+
try {
|
|
119
|
+
res = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(batch), signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { retryAfter: null };
|
|
123
|
+
}
|
|
124
|
+
if (res.ok) {
|
|
125
|
+
await res.body?.cancel();
|
|
126
|
+
return 'sent';
|
|
127
|
+
}
|
|
128
|
+
// llms.ink's problem details (`code`, `detail`) say why a batch was refused.
|
|
129
|
+
const problem = await res.json().catch(() => null);
|
|
130
|
+
const why = problem ? `${problem.code ?? ''}: ${problem.detail ?? ''}` : '';
|
|
131
|
+
if (res.status === 429 || res.status >= 500)
|
|
132
|
+
return { retryAfter: Number(res.headers.get('retry-after')) || null };
|
|
133
|
+
if (res.status === 401 || res.status === 403) {
|
|
134
|
+
stop(`llms.ink refused ${site} (${res.status} ${why || 'check the key'}).`);
|
|
135
|
+
return 'stop';
|
|
136
|
+
}
|
|
137
|
+
console.warn(`llms-ink traffic: llms.ink rejected a batch for ${site} (${res.status} ${why}).`);
|
|
138
|
+
return 'drop';
|
|
139
|
+
}
|
|
140
|
+
async function sendAll(rows) {
|
|
141
|
+
const batches = [];
|
|
142
|
+
const byPeriod = new Map();
|
|
143
|
+
for (const r of rows)
|
|
144
|
+
byPeriod.set(r.period, [...(byPeriod.get(r.period) ?? []), r]);
|
|
145
|
+
for (const [period, list] of byPeriod) {
|
|
146
|
+
for (let i = 0; i < list.length; i += ROWS_PER_POST) {
|
|
147
|
+
const chunk = list.slice(i, i + ROWS_PER_POST);
|
|
148
|
+
batches.push({ batch: { site, period, rows: chunk.map(({ path, agent, kind, count }) => ({ path, agent, kind, count })) }, rows: chunk });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (let i = 0; i < batches.length; i++) {
|
|
152
|
+
const outcome = await send(batches[i].batch);
|
|
153
|
+
if (outcome === 'stop')
|
|
154
|
+
return;
|
|
155
|
+
if (typeof outcome === 'object') {
|
|
156
|
+
retryLater(batches.slice(i).flatMap((b) => b.rows), outcome.retryAfter);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
backoff = 0;
|
|
161
|
+
retryAt = 0;
|
|
162
|
+
}
|
|
163
|
+
/** Waits for a send in flight, then sends what was counted meanwhile. */
|
|
164
|
+
async function flush() {
|
|
165
|
+
while (inflight)
|
|
166
|
+
await inflight;
|
|
167
|
+
if (!pending.size || stopped)
|
|
168
|
+
return;
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
timer = undefined;
|
|
171
|
+
const rows = [...pending.values()];
|
|
172
|
+
pending.clear();
|
|
173
|
+
lastSend = Date.now();
|
|
174
|
+
inflight = sendAll(rows)
|
|
175
|
+
.catch(() => { })
|
|
176
|
+
.finally(() => {
|
|
177
|
+
inflight = null;
|
|
178
|
+
if (pending.size && !stopped)
|
|
179
|
+
arm();
|
|
180
|
+
});
|
|
181
|
+
await inflight;
|
|
182
|
+
}
|
|
183
|
+
/** A background send where nothing keeps one alive (Node, Bun, Deno); unref'd so it never holds the process open. */
|
|
184
|
+
function arm() {
|
|
185
|
+
if (timer)
|
|
186
|
+
return;
|
|
187
|
+
const delay = Math.max(1000, Math.max(lastSend + every, retryAt) - Date.now());
|
|
188
|
+
timer = setTimeout(() => {
|
|
189
|
+
timer = undefined;
|
|
190
|
+
void flush();
|
|
191
|
+
}, delay);
|
|
192
|
+
// Node's and Bun's timers have unref(); elsewhere setTimeout returns a number.
|
|
193
|
+
if (typeof timer === 'object' && 'unref' in timer)
|
|
194
|
+
timer.unref();
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
record(request, ctx) {
|
|
198
|
+
try {
|
|
199
|
+
if (stopped || (request.method !== 'GET' && request.method !== 'HEAD'))
|
|
200
|
+
return;
|
|
201
|
+
const agent = classifyAgent(request.headers.get('user-agent'));
|
|
202
|
+
if (!agent)
|
|
203
|
+
return;
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
add(new Date(now).toISOString().slice(0, 10), cleanPath(request.url), agent.agent, agent.kind, 1);
|
|
206
|
+
const due = now - lastSend >= every || pending.size >= ROWS_PER_POST;
|
|
207
|
+
if (!inflight && now >= retryAt && due) {
|
|
208
|
+
const sending = flush();
|
|
209
|
+
ctx?.waitUntil(sending);
|
|
210
|
+
}
|
|
211
|
+
else if (!ctx) {
|
|
212
|
+
arm();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Counting must never break the site.
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
flush,
|
|
220
|
+
};
|
|
221
|
+
}
|
package/dist/vite.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Vite plugin: writes llms.txt (and llms-full.txt) from llms.ink into the build output
|
|
2
|
+
* Vite plugin: writes llms.txt (and llms-full.txt) from llms.ink into the build output, and with
|
|
3
|
+
* `markdownPages: true` a Markdown version next to every built HTML page.
|
|
3
4
|
*
|
|
4
5
|
* @example
|
|
5
6
|
* ```ts
|
|
@@ -13,13 +14,14 @@
|
|
|
13
14
|
* @module
|
|
14
15
|
*/
|
|
15
16
|
import type { Plugin } from 'vite';
|
|
16
|
-
import { type LlmsFilesOptions } from './files.ts';
|
|
17
|
-
export type { LlmsFilesOptions };
|
|
17
|
+
import { type LlmsFilesOptions, type MarkdownPagesOptions } from './files.ts';
|
|
18
|
+
export type { LlmsFilesOptions, MarkdownPagesOptions };
|
|
18
19
|
/**
|
|
19
20
|
* After `vite build` (not the dev server, not SSR builds), fetches llms.txt — and llms-full.txt with
|
|
20
21
|
* `full: true` — for `site` and writes them into `build.outDir`. llms.ink reads the live site, so the files
|
|
21
22
|
* describe the previous deploy. When llms.ink can't be reached it warns and keeps any existing file (e.g. one
|
|
22
|
-
* copied from `public/`); `strict: true` fails the build instead.
|
|
23
|
+
* copied from `public/`); `strict: true` fails the build instead. `markdownPages: true` converts each `.html` file
|
|
24
|
+
* of this build (not the deploy) through `POST /api/v0/markdown` and writes `about.md` / `docs/index.md` next to it.
|
|
23
25
|
*/
|
|
24
26
|
export declare function llmsInk(options: LlmsFilesOptions): Plugin;
|
|
25
27
|
export default llmsInk;
|
package/dist/vite.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Vite plugin: writes llms.txt (and llms-full.txt) from llms.ink into the build output
|
|
2
|
+
* Vite plugin: writes llms.txt (and llms-full.txt) from llms.ink into the build output, and with
|
|
3
|
+
* `markdownPages: true` a Markdown version next to every built HTML page.
|
|
3
4
|
*
|
|
4
5
|
* @example
|
|
5
6
|
* ```ts
|
|
@@ -18,7 +19,8 @@ import { llmsFilesWriter } from "./files.js";
|
|
|
18
19
|
* After `vite build` (not the dev server, not SSR builds), fetches llms.txt — and llms-full.txt with
|
|
19
20
|
* `full: true` — for `site` and writes them into `build.outDir`. llms.ink reads the live site, so the files
|
|
20
21
|
* describe the previous deploy. When llms.ink can't be reached it warns and keeps any existing file (e.g. one
|
|
21
|
-
* copied from `public/`); `strict: true` fails the build instead.
|
|
22
|
+
* copied from `public/`); `strict: true` fails the build instead. `markdownPages: true` converts each `.html` file
|
|
23
|
+
* of this build (not the deploy) through `POST /api/v0/markdown` and writes `about.md` / `docs/index.md` next to it.
|
|
22
24
|
*/
|
|
23
25
|
export function llmsInk(options) {
|
|
24
26
|
const write = llmsFilesWriter(options);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llms-ink",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Turn any link — site, docs section, page, GitHub repo, PDF or video — into llms.txt and llms-full.txt. Typed client for llms.ink, plus a Vite plugin, an Astro integration and middleware that serve the files from your site. No dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -25,13 +25,15 @@
|
|
|
25
25
|
"types": "./dist/index.d.ts",
|
|
26
26
|
"files": ["dist"],
|
|
27
27
|
"sideEffects": false,
|
|
28
|
+
"repository": { "type": "git", "url": "git+https://github.com/lukax/llms-ink-js.git" },
|
|
29
|
+
"bugs": "https://github.com/lukax/llms-ink-js/issues",
|
|
28
30
|
"engines": { "node": ">=18" },
|
|
29
31
|
"scripts": {
|
|
30
32
|
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
31
33
|
"prepublishOnly": "npm run build && npm test",
|
|
32
|
-
"test": "npm run build && node --test test/client.test.mjs test/plugins.test.mjs test/middleware.test.mjs",
|
|
34
|
+
"test": "npm run build && node --test test/client.test.mjs test/plugins.test.mjs test/middleware.test.mjs test/traffic.test.mjs",
|
|
33
35
|
"test:live": "npm run build && node --test test/live.test.mjs",
|
|
34
|
-
"test:bun": "npm run build && bun test ./test/client.test.mjs ./test/plugins.test.mjs ./test/middleware.test.mjs",
|
|
36
|
+
"test:bun": "npm run build && bun test ./test/client.test.mjs ./test/plugins.test.mjs ./test/middleware.test.mjs ./test/traffic.test.mjs",
|
|
35
37
|
"test:bun:live": "npm run build && bun test --timeout 300000 ./test/live.test.mjs"
|
|
36
38
|
},
|
|
37
39
|
"keywords": ["llms.txt", "llms-full.txt", "llm", "ai", "markdown", "docs", "context", "rag", "sdk", "llms-ink", "vite-plugin", "astro-integration", "middleware"],
|