Skip to content

Commit 4430eec

Browse files
authored
docs: add llms.txt and LLM-friendly Markdown documentation (#96)
1 parent 4841fcc commit 4430eec

8 files changed

Lines changed: 756 additions & 9 deletions

File tree

‎docs/articles/working-with-ai.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Working with AI Coding Agents
2+
3+
probe.gl publishes a machine-readable documentation index at
4+
[`llms.txt`](https://visgl.github.io/probe.gl/llms.txt). Each entry links to a Markdown
5+
version of a documentation page. Fetch the index first, then read the pages relevant
6+
to your task. Both are generated from the website sources on each production build.
7+
8+
## Start from your installed version
9+
10+
Check the application's package manifest, lockfile, and installed declarations before
11+
using an API. The website describes current documentation; an older installed
12+
package may expose a different API. See the [upgrade guide](../upgrade-guide.md)
13+
when migrating older versions. Install scoped packages such as `@probe.gl/log` or
14+
`@probe.gl/stats`; the legacy unscoped `probe.gl` package was removed in v4.
15+
16+
## Choose the relevant documentation
17+
18+
- [Logging and instrumentation](../get-started/adding-probes.md) explains log levels
19+
and configuration. Logging methods return a function: call the second pair of
20+
parentheses to emit output, for example `log.info('Ready')()`.
21+
- [Stats](../modules/stats/stats.md) and [Stat](../modules/stats/stat.md) describe
22+
counters, timing, and sample windows. Check the documented units when displaying
23+
elapsed times or comparing measurements.
24+
- [Benchmarking](../get-started/benchmarking.md) and
25+
[Bench](../modules/bench/bench.md) explain benchmark suites. Compare results under
26+
the same runtime and workload and account for warmup and measurement variability.
27+
- [Environment utilities](../modules/env.md) describe portable runtime detection.
28+
The [stats widget](../modules/stats-widget/stats-widget.md) requires a DOM;
29+
[browser automation](../modules/test-utils/browser-driver.md) runs from Node.js.
30+
31+
## Verify changes in the repository
32+
33+
Read the affected module's source and tests before changing behavior. Run
34+
`yarn lint` and `yarn test-node` for code changes, and use `yarn test-headless` when
35+
browser behavior matters. Build the documentation with `cd website && yarn build`;
36+
this also checks the generated index and Markdown links.
37+
38+
The machine-readable output includes current documentation. Standalone website
39+
pages, examples, blogs, and older versioned documentation are excluded. There is
40+
no combined `llms-full.txt`; follow individual links to retrieve focused context.

‎docs/table-of-contents.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"items": [
2525
"articles/about-logging",
2626
"articles/about-benchmarking",
27-
"articles/about-testing"
27+
"articles/about-testing",
28+
"articles/working-with-ai"
2829
]
2930
},
3031
{

‎website/docusaurus.config.js‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,35 @@ config.future = {
3636
faster: true
3737
};
3838

39+
const websiteRoutePrefix = config.baseUrl.replace(/\/$/, '');
40+
config.plugins = [
41+
...(config.plugins || []),
42+
[
43+
'@signalwire/docusaurus-plugin-llms-txt',
44+
{
45+
siteTitle: 'probe.gl',
46+
siteDescription:
47+
'TypeScript logging, instrumentation, benchmarking and test utilities for browsers and Node.js.',
48+
// Include the base path and nested module API routes in the index hierarchy.
49+
depth: 5,
50+
enableDescriptions: true,
51+
includeOrder: ['/docs', '/docs/get-started/**', '/docs/articles/**', '/docs/modules/**'].map(
52+
(route) => `${websiteRoutePrefix}${route}`
53+
),
54+
onRouteError: 'throw',
55+
content: {
56+
enableMarkdownFiles: true,
57+
enableLlmsFullTxt: false,
58+
relativePaths: false,
59+
includeBlog: false,
60+
includePages: false,
61+
includeDocs: true,
62+
includeVersionedDocs: false,
63+
includeGeneratedIndex: true,
64+
excludeRoutes: [`${websiteRoutePrefix}/examples/**`]
65+
}
66+
}
67+
]
68+
];
69+
3970
module.exports = config;

‎website/package.json‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,30 @@
66
"scripts": {
77
"docusaurus": "docusaurus",
88
"start": "docusaurus start",
9-
"build": "docusaurus build",
9+
"build": "docusaurus build && node ./scripts/normalize-llm-output.mjs && node ./scripts/check-llm-output.mjs",
1010
"swizzle": "docusaurus swizzle",
1111
"clear": "docusaurus clear",
1212
"serve": "docusaurus serve",
1313
"write-heading-ids": "ocular-doc-headers ../docs",
14-
"deploy": "docusaurus deploy"
14+
"deploy": "docusaurus deploy",
15+
"test:llm-output": "node --test scripts/check-llm-output.test.mjs"
1516
},
1617
"dependencies": {
1718
"@docusaurus/core": "^3.10.2",
1819
"@fontsource-variable/source-sans-3": "^5.2.7",
1920
"@fontsource-variable/space-grotesk": "^5.2.7",
2021
"@fontsource/ibm-plex-mono": "^5.2.7",
2122
"@mdx-js/react": "^3.1.1",
23+
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
2224
"@vis.gl/docusaurus-website": "2.0.0-alpha.6",
2325
"prism-react-renderer": "^2.4.1",
2426
"react": "^19.2.8",
2527
"react-dom": "^19.2.8"
2628
},
2729
"devDependencies": {
28-
"@types/react": "^19.2.18"
30+
"@types/react": "^19.2.18",
31+
"remark-parse": "^11.0.0",
32+
"unified": "^11.0.5"
2933
},
3034
"browserslist": [
3135
">0.2% and supports async-functions",
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import {existsSync, readdirSync, readFileSync, statSync} from 'node:fs';
2+
import path from 'node:path';
3+
import {fileURLToPath} from 'node:url';
4+
import {unified} from 'unified';
5+
import remarkParse from 'remark-parse';
6+
7+
const websiteDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8+
const buildDirectory = path.join(websiteDirectory, 'build');
9+
const llmsTxtPath = path.join(buildDirectory, 'llms.txt');
10+
const llmsFullTxtPath = path.join(buildDirectory, 'llms-full.txt');
11+
const websiteOrigin = 'https://visgl.github.io';
12+
const websiteBasePath = normalizeWebsiteBasePath('/probe.gl/');
13+
const websiteUrl = new URL(websiteBasePath, websiteOrigin);
14+
const duplicatedWebsiteBaseUrl =
15+
websiteBasePath === '/' ? null : new URL(websiteBasePath.slice(1), websiteUrl).href;
16+
17+
function normalizeWebsiteBasePath(basePath) {
18+
const pathSegments = basePath.split('/').filter(Boolean);
19+
return pathSegments.length === 0 ? '/' : `/${pathSegments.join('/')}/`;
20+
}
21+
22+
function stripWebsiteBasePath(pathname) {
23+
if (websiteBasePath === '/' || !pathname.startsWith(websiteBasePath)) {
24+
return pathname;
25+
}
26+
return `/${pathname.slice(websiteBasePath.length)}`;
27+
}
28+
29+
function fail(message) {
30+
throw new Error(`llms.txt output check failed: ${message}`);
31+
}
32+
33+
function requireFile(relativePath) {
34+
const filePath = path.join(buildDirectory, relativePath);
35+
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
36+
fail(`missing ${relativePath}`);
37+
}
38+
return filePath;
39+
}
40+
41+
function findFiles(directory, extension) {
42+
if (!existsSync(directory)) {
43+
return [];
44+
}
45+
46+
const filePaths = [];
47+
for (const entry of readdirSync(directory, {withFileTypes: true})) {
48+
const entryPath = path.join(directory, entry.name);
49+
if (entry.isDirectory()) {
50+
filePaths.push(...findFiles(entryPath, extension));
51+
} else if (entry.isFile() && entryPath.endsWith(extension)) {
52+
filePaths.push(entryPath);
53+
}
54+
}
55+
return filePaths;
56+
}
57+
58+
const markdownParser = unified().use(remarkParse);
59+
60+
function extractMarkdownLinks(markdown) {
61+
const links = [];
62+
function visit(node) {
63+
// Definitions supply destinations for full, collapsed, and shortcut references.
64+
if (['link', 'image', 'definition'].includes(node.type)) {
65+
links.push(node.url);
66+
}
67+
for (const child of node.children || []) {
68+
visit(child);
69+
}
70+
}
71+
visit(markdownParser.parse(markdown));
72+
return links;
73+
}
74+
75+
function resolveGeneratedMarkdownLink(sourcePath, link) {
76+
if (link.startsWith('#') || link.startsWith('mailto:')) {
77+
return null;
78+
}
79+
80+
let pathname;
81+
try {
82+
const url = new URL(link);
83+
if (url.origin !== websiteUrl.origin) {
84+
return null;
85+
}
86+
pathname = decodeURIComponent(url.pathname);
87+
} catch {
88+
const linkWithoutFragment = link.split('#', 1)[0].split('?', 1)[0];
89+
if (!linkWithoutFragment.endsWith('.md')) {
90+
return null;
91+
}
92+
if (linkWithoutFragment.startsWith('/')) {
93+
pathname = decodeURIComponent(linkWithoutFragment);
94+
} else {
95+
return path.resolve(path.dirname(sourcePath), decodeURIComponent(linkWithoutFragment));
96+
}
97+
}
98+
99+
if (!pathname.endsWith('.md')) {
100+
return null;
101+
}
102+
pathname = stripWebsiteBasePath(pathname);
103+
if (path.isAbsolute(pathname) && pathname.startsWith(buildDirectory)) {
104+
return pathname;
105+
}
106+
return path.join(buildDirectory, pathname.replace(/^\/+/, ''));
107+
}
108+
109+
if (!existsSync(llmsTxtPath)) {
110+
fail('missing llms.txt');
111+
}
112+
if (existsSync(llmsFullTxtPath)) {
113+
fail('llms-full.txt must not be generated');
114+
}
115+
116+
const llmsTxt = readFileSync(llmsTxtPath, 'utf8');
117+
if (!llmsTxt.startsWith('# probe.gl\n')) {
118+
fail('llms.txt has an unexpected title');
119+
}
120+
const firstSectionHeading = llmsTxt.match(/^## .+$/m)?.[0];
121+
if (firstSectionHeading !== '## docs') {
122+
fail(`llms.txt has an unexpected first section: ${firstSectionHeading || 'none'}`);
123+
}
124+
if (llmsTxt.includes('/examples/')) {
125+
fail('llms.txt contains a standalone example page');
126+
}
127+
128+
// Use Docusaurus metadata rather than the generated Markdown as the coverage source.
129+
// Permalinks account for frontmatter slugs and the website's route normalization.
130+
const docsMetadataDirectory = path.join(
131+
websiteDirectory, '.docusaurus/docusaurus-plugin-content-docs/default'
132+
);
133+
const currentDocs = findFiles(docsMetadataDirectory, '.json')
134+
.map((filePath) => JSON.parse(readFileSync(filePath, 'utf8')))
135+
.filter((doc) => doc.version === 'current' && doc.permalink && !doc.draft);
136+
if (currentDocs.length === 0) {
137+
fail('missing current documentation route metadata');
138+
}
139+
for (const doc of currentDocs) {
140+
const pathname = new URL(doc.permalink, websiteUrl).pathname.replace(/\/$/, '') + '.md';
141+
const pageUrl = new URL(pathname, websiteUrl).href;
142+
if (!llmsTxt.includes(pageUrl)) {
143+
fail(`llms.txt is missing ${pageUrl}`);
144+
}
145+
requireFile(stripWebsiteBasePath(pathname).replace(/^\/+/, ''));
146+
}
147+
148+
const markdownFiles = findFiles(buildDirectory, '.md');
149+
150+
const brokenLinks = [];
151+
for (const markdownPath of [llmsTxtPath, ...markdownFiles]) {
152+
const markdown = readFileSync(markdownPath, 'utf8');
153+
const relativeMarkdownPath = path.relative(buildDirectory, markdownPath);
154+
if (markdownPath !== llmsTxtPath) {
155+
const pageUrl = new URL(relativeMarkdownPath.split(path.sep).join('/'), websiteUrl).href;
156+
if (!llmsTxt.includes(pageUrl)) {
157+
fail(`llms.txt is missing generated page ${pageUrl}`);
158+
}
159+
}
160+
if (markdown.trim().length < 40) {
161+
fail(`${relativeMarkdownPath} has empty extracted content`);
162+
}
163+
if (duplicatedWebsiteBaseUrl && markdown.includes(duplicatedWebsiteBaseUrl)) {
164+
fail(`${relativeMarkdownPath} contains a duplicated website base path`);
165+
}
166+
167+
for (const link of extractMarkdownLinks(markdown)) {
168+
const targetPath = resolveGeneratedMarkdownLink(markdownPath, link);
169+
if (targetPath && !existsSync(targetPath)) {
170+
brokenLinks.push(
171+
`${path.relative(buildDirectory, markdownPath)} -> ${path.relative(
172+
buildDirectory,
173+
targetPath
174+
)}`
175+
);
176+
}
177+
}
178+
}
179+
180+
if (brokenLinks.length > 0) {
181+
fail(`broken Markdown links:\n${brokenLinks.slice(0, 20).join('\n')}`);
182+
}
183+
184+
console.log(`Validated llms.txt and ${markdownFiles.length} raw documentation pages.`);
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import assert from 'node:assert/strict';
2+
import {mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync} from 'node:fs';
3+
import path from 'node:path';
4+
import {spawnSync} from 'node:child_process';
5+
import {test} from 'node:test';
6+
import {fileURLToPath} from 'node:url';
7+
8+
const scriptsDirectory = path.dirname(fileURLToPath(import.meta.url));
9+
const websiteDirectory = path.dirname(scriptsDirectory);
10+
const checker = readFileSync(path.join(scriptsDirectory, 'check-llm-output.mjs'), 'utf8');
11+
const siteUrl = 'https://visgl.github.io/probe.gl/';
12+
13+
function fixture(run) {
14+
// Keep fixtures under the website so the checker resolves its declared dependencies.
15+
const directory = mkdtempSync(path.join(websiteDirectory, '.llm-output-test-'));
16+
const write = (relativePath, content) => {
17+
const filePath = path.join(directory, relativePath);
18+
mkdirSync(path.dirname(filePath), {recursive: true});
19+
writeFileSync(filePath, content);
20+
};
21+
const pages = ['docs.md', 'docs/custom-route.md'];
22+
const index = '# probe.gl\n\n## docs\n\n' + pages.map(
23+
(page) => `- [Documentation](${siteUrl}${page})`
24+
).join('\n');
25+
write('scripts/check-llm-output.mjs', checker);
26+
write('build/llms.txt', index);
27+
for (const page of pages) {
28+
write(`build/${page}`, '# Documentation\n\nA current documentation page with useful content.\n');
29+
}
30+
for (const [i, permalink] of ['/probe.gl/docs/', '/probe.gl/docs/custom-route'].entries()) {
31+
write(`.docusaurus/docusaurus-plugin-content-docs/default/doc-${i}.json`,
32+
JSON.stringify({version: 'current', permalink, draft: false}));
33+
}
34+
// Versioned documents and drafts are not expected in current generated output.
35+
write('.docusaurus/docusaurus-plugin-content-docs/default/old.json',
36+
JSON.stringify({version: 'v1', permalink: '/probe.gl/docs/v1/old'}));
37+
write('.docusaurus/docusaurus-plugin-content-docs/default/draft.json',
38+
JSON.stringify({version: 'current', permalink: '/probe.gl/docs/draft', draft: true}));
39+
const check = () => spawnSync(process.execPath,
40+
[path.join(directory, 'scripts/check-llm-output.mjs')], {encoding: 'utf8'});
41+
try {
42+
run({write, check, directory, index});
43+
} finally {
44+
rmSync(directory, {recursive: true, force: true});
45+
}
46+
}
47+
48+
test('valid current routes, custom slugs, and reference links pass', () => fixture(({write, check}) => {
49+
write('build/docs.md', '# Documentation\n\n[API][api]\n\n[api]: ./docs/custom-route.md\n');
50+
const result = check();
51+
assert.equal(result.status, 0, result.stderr);
52+
}));
53+
54+
for (const reference of ['[API][api]', '[api][]', '[api]', '![API][api]']) {
55+
test(`rejects broken reference link: ${reference}`, () => fixture(({write, check}) => {
56+
write('build/docs.md', `# Documentation\n\n${reference}\n\n[api]: ./missing.md\n`);
57+
const result = check();
58+
assert.notEqual(result.status, 0);
59+
assert.match(result.stderr, /broken Markdown links/);
60+
}));
61+
}
62+
63+
test('ignores Markdown-looking links inside code fences', () => fixture(({write, check}) => {
64+
write('build/docs.md', '# Documentation\n\n```md\n[API](./missing.md)\n[api]: ./missing.md\n```\n');
65+
const result = check();
66+
assert.equal(result.status, 0, result.stderr);
67+
}));
68+
69+
test('rejects a current page missing from both files and index', () => fixture(({write, check, directory}) => {
70+
rmSync(path.join(directory, 'build/docs/custom-route.md'));
71+
write('build/llms.txt', '# probe.gl\n\n## docs\n\n' + `- [Documentation](${siteUrl}docs.md)\n`);
72+
const result = check();
73+
assert.notEqual(result.status, 0);
74+
assert.match(result.stderr, /is missing .*custom-route.md/);
75+
}));
76+
77+
test('rejects an indexed current page without its generated file', () => fixture(({check, directory}) => {
78+
rmSync(path.join(directory, 'build/docs/custom-route.md'));
79+
const result = check();
80+
assert.notEqual(result.status, 0);
81+
assert.match(result.stderr, /missing docs\/custom-route.md/);
82+
}));

0 commit comments

Comments
 (0)