mirror of
https://github.com/only-cli/oc.git
synced 2026-09-15 10:40:56 +02:00
Merge pull request #26 from only-cli/feat/language-docs
Add language documentation shortcuts (#25): Python, MDN, Node.js, Ruby, Go, Rust, Java, PHP, TypeScript, C/C++, and .NET
This commit is contained in:
@@ -3,6 +3,17 @@
|
||||
Notable changes per release. Releases before 0.4.0 are listed at
|
||||
[github.com/only-cli/oc/releases](https://github.com/only-cli/oc/releases).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Language documentation shortcuts: `py`, `mdn`, `node`, `ruby`, `go`, `rust`,
|
||||
`java`, `php`, `cpp`, and `ts`, plus a `dotnet` verb on `learn` for the .NET
|
||||
API browser. `search` on `py`, `node`, and `ruby` ranks the docs' own search
|
||||
index locally and on `mdn` asks the site's API; the sites that only render
|
||||
docs search client-side go through DuckDuckGo with a baked-in `site:` filter
|
||||
instead. (#25)
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Added
|
||||
|
||||
@@ -146,23 +146,33 @@ Works on any mostly-static site with no per-site setup: news sites, blogs, docum
|
||||
| Wikipedia | `oc wiki` (via `action=render`) | `article <title>`, `search <query>`, `lang <code> <title>` |
|
||||
| AWS docs | `oc aws` (search via DuckDuckGo) | `guide <service> <page>`, `page <service> <guide> <page>`, `cli <command>`, `search <query>` |
|
||||
| Google Cloud docs | `oc gcp` (via docs.cloud.google.com, search via DuckDuckGo) | `docs <product>`, `page <product> <page>`, `gcloud <command>`, `search <query>` |
|
||||
| Microsoft Learn | `oc learn` (search via its RSS API) | `azure <page>`, `doc <path>`, `cli <command>`, `search <query>` |
|
||||
| Microsoft Learn | `oc learn` (search via its RSS API, covers .NET) | `azure <page>`, `doc <path>`, `dotnet <api>`, `cli <command>`, `search <query>` |
|
||||
| Python docs | `oc py` (search via the docs' own index) | `library <module>`, `doc <path>`, `search <query>` |
|
||||
| MDN | `oc mdn` (search via the site's own API) | `js <page>`, `css <page>`, `doc <path>`, `search <query>` |
|
||||
| Node.js docs | `oc node` (search via the docs' own reference) | `api <module>`, `search <query>` |
|
||||
| Ruby docs | `oc ruby` (search via the docs' own index) | `class <class>`, `search <query>` |
|
||||
| Go packages | `oc go` (pkg.go.dev, server-rendered search) | `pkg <path>`, `search <query>` |
|
||||
| PHP manual | `oc php` (an exact `fn` name lands on its page, search via DuckDuckGo) | `fn <name>`, `doc <path>`, `search <query>` |
|
||||
| Rust docs | `oc rust` (search via DuckDuckGo) | `std <path>`, `doc <path>`, `search <query>` |
|
||||
| Java docs | `oc java` (Javadoc for the current JDK, search via DuckDuckGo) | `api <path>`, `search <query>` |
|
||||
| C and C++ | `oc cpp` (cppreference.com, search via DuckDuckGo) | `cpp <path>`, `c <path>`, `search <query>` |
|
||||
| TypeScript | `oc ts` (search via DuckDuckGo) | `handbook <page>`, `search <query>` |
|
||||
|
||||
A shortcut only ever resolves to a URL and then takes the same path `oc open` does, so it changes nothing about what a page costs or how it reads. The last argument takes every word after it, so `oc ddg search claude code cli` and `oc aws search s3 lifecycle rules` need no quoting, and a path argument keeps its slashes, so `oc learn doc azure/aks/what-is-aks` reaches that page.
|
||||
|
||||
A few of these (X, Stack Overflow, YouTube, Microsoft Learn search) read pages that look login-gated or JS-only from the outside, by finding the server-rendered HTML, feed, inline data, or public API the page already ships without a login. Stack Overflow search goes through the Stack Exchange API, and each result prints its `question_id`: read one with the `question <id>` feed rather than following its link, since the question page itself answers a bot challenge instead of the question. AWS and Google Cloud render docs search purely client-side with no feed, so their `search` goes through DuckDuckGo with a baked-in `site:` filter instead. Not supported yet: pages that only render with JavaScript and sites with hard bot challenges that expose no feed. Sites that genuinely require your account can be reached with `oc login` (bring your own cookies).
|
||||
A few of these (X, Stack Overflow, YouTube, Microsoft Learn search) read pages that look login-gated or JS-only from the outside, by finding the server-rendered HTML, feed, inline data, or public API the page already ships without a login. Stack Overflow search goes through the Stack Exchange API, and each result prints its `question_id`: read one with the `question <id>` feed rather than following its link, since the question page itself answers a bot challenge instead of the question. AWS, Google Cloud, Rust, Java, TypeScript, PHP, and cppreference render docs search client-side, or as a page too bare for oc to read, so their `search` goes through DuckDuckGo with a baked-in `site:` filter instead; Go needs no such fallback, because pkg.go.dev renders its search results on the server and `oc go search` simply opens them. Python's docs are built with Sphinx, which publishes the site's full-text search index as one static file, so `oc py search` fetches that index (cached on disk for a day), ranks it locally, and prints a numbered result list; a query that names a symbol exactly, like `json.dumps`, links straight to its anchor. The same backend will work for any Sphinx site, including most Read the Docs projects. MDN also renders its search client-side, but the page gets its results from a public JSON endpoint, so `oc mdn search` asks that endpoint directly and prints the site's own ranking; that `api` shape in a site definition works for any site whose search answers as JSON. Node.js ships no search endpoint at all, but publishes its whole API reference as one static JSON file, so `oc node search` ranks that file locally the same way the Sphinx backend does, under the same day cache, and every module, class, method, property, and event heading links to its own anchor. Ruby's docs are built with RDoc, which also ships its search index as one static file, so `oc ruby search` ranks every class, method, and guide page locally the same way. PHP's manual has a lookup endpoint that sends an exact function name straight to its page, which is what `oc php fn` rides. Not supported yet: pages that only render with JavaScript and sites with hard bot challenges that expose no feed. Sites that genuinely require your account can be reached with `oc login` (bring your own cookies).
|
||||
|
||||
Want a website on that list? Open a pull request, or an issue naming the site; see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Full methodology, per-task numbers, and other agents/models live in [only-cli/benchmarks](https://github.com/only-cli/benchmarks). The short version, measured with oc 0.4.0 on 2026-08-24 against live sites across a news front page, a Reddit discussion, a search results page, a stock quote, three cloud CLI reference pages, and more:
|
||||
Full methodology, per-task numbers, and other agents/models live in [only-cli/benchmarks](https://github.com/only-cli/benchmarks). The short version, measured with oc 0.4.0 on 2026-08-24 against live sites across a news front page, a Reddit discussion, a search results page, a stock quote, three cloud CLI reference pages, the Python, MDN, and Node.js references, and more:
|
||||
|
||||
| method | tokens for 12 real pages | notes |
|
||||
| method | tokens for 15 real pages | notes |
|
||||
| --- | ---: | --- |
|
||||
| `oc open` | 9,487 | only method that returned real content on every page |
|
||||
| Jina Reader | 90,929 | blocked on both Reddit pages, failed the stock quote page |
|
||||
| raw HTML fetch | 1,183,149 | the stock quote page alone is 371,597 tokens |
|
||||
| `oc open` | 10,973 | only method that returned real content on every page |
|
||||
| Jina Reader | 170,505 | blocked on both Reddit pages, failed the LinkedIn page |
|
||||
| raw HTML fetch | 1,535,791 | the stock quote page alone is 375,721 tokens |
|
||||
|
||||
Read cost is one thing, but what an agent actually spends is another, so a
|
||||
second suite runs whole tasks end to end in Claude Code and compares `oc`
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"domain": "developer.mozilla.org",
|
||||
"commands": {
|
||||
"js": { "open": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/{page}", "args": ["page"] },
|
||||
"css": { "open": "https://developer.mozilla.org/en-US/docs/Web/CSS/{page}", "args": ["page"] },
|
||||
"doc": { "open": "https://developer.mozilla.org/en-US/docs/{path}", "args": ["path"] },
|
||||
"search": { "api": "https://developer.mozilla.org/api/v1/search?q={query}&locale=en-US", "page": "https://developer.mozilla.org/en-US/search?q={query}", "results": "documents", "fields": { "title": "title", "url": "mdn_url", "text": "summary" }, "total": "metadata.total.value", "args": ["query"] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "doc.rust-lang.org",
|
||||
"commands": {
|
||||
"std": { "open": "https://doc.rust-lang.org/std/{path}.html", "args": ["path"] },
|
||||
"doc": { "open": "https://doc.rust-lang.org/{path}.html", "args": ["path"] },
|
||||
"search": { "open": "https://html.duckduckgo.com/html/?q=site%3Adoc.rust-lang.org+{query}", "args": ["query"] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"domain": "docs.oracle.com",
|
||||
"commands": {
|
||||
"api": { "open": "https://docs.oracle.com/en/java/javase/26/docs/api/{path}.html", "args": ["path"] },
|
||||
"search": { "open": "https://html.duckduckgo.com/html/?q=site%3Adocs.oracle.com+javase+{query}", "args": ["query"] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "docs.python.org",
|
||||
"commands": {
|
||||
"library": { "open": "https://docs.python.org/3/library/{module}.html", "args": ["module"] },
|
||||
"doc": { "open": "https://docs.python.org/3/{path}.html", "args": ["path"] },
|
||||
"search": { "sphinx": "https://docs.python.org/3/", "args": ["query"] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"domain": "docs.ruby-lang.org",
|
||||
"commands": {
|
||||
"class": { "open": "https://docs.ruby-lang.org/en/3.4/{class}.html", "args": ["class"] },
|
||||
"search": { "rdoc": "https://docs.ruby-lang.org/en/3.4/", "args": ["query"] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "en.cppreference.com",
|
||||
"commands": {
|
||||
"cpp": { "open": "https://en.cppreference.com/cpp/{path}", "args": ["path"] },
|
||||
"c": { "open": "https://en.cppreference.com/c/{path}", "args": ["path"] },
|
||||
"search": { "open": "https://html.duckduckgo.com/html/?q=site%3Aen.cppreference.com+{query}", "args": ["query"] }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"azure": { "open": "https://learn.microsoft.com/en-us/azure/{page}", "args": ["page"] },
|
||||
"doc": { "open": "https://learn.microsoft.com/en-us/{path}", "args": ["path"] },
|
||||
"cli": { "open": "https://learn.microsoft.com/en-us/cli/azure/{command}", "args": ["command"] },
|
||||
"dotnet": { "open": "https://learn.microsoft.com/en-us/dotnet/api/{api}", "args": ["api"] },
|
||||
"search": { "open": "https://learn.microsoft.com/api/search/rss?search={query}&locale=en-us", "args": ["query"] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"domain": "nodejs.org",
|
||||
"commands": {
|
||||
"api": { "open": "https://nodejs.org/api/{module}.html", "args": ["module"] },
|
||||
"search": { "nodedoc": "https://nodejs.org/api/", "args": ["query"] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "php.net",
|
||||
"commands": {
|
||||
"fn": { "open": "https://www.php.net/manual-lookup.php?pattern={name}", "args": ["name"] },
|
||||
"doc": { "open": "https://www.php.net/manual/en/{path}.php", "args": ["path"] },
|
||||
"search": { "open": "https://html.duckduckgo.com/html/?q=site%3Aphp.net+{query}", "args": ["query"] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"domain": "pkg.go.dev",
|
||||
"commands": {
|
||||
"pkg": { "open": "https://pkg.go.dev/{path}", "args": ["path"] },
|
||||
"search": { "open": "https://pkg.go.dev/search?q={query}", "args": ["query"] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"domain": "typescriptlang.org",
|
||||
"commands": {
|
||||
"handbook": { "open": "https://www.typescriptlang.org/docs/handbook/{page}.html", "args": ["page"] },
|
||||
"search": { "open": "https://html.duckduckgo.com/html/?q=site%3Atypescriptlang.org+{query}", "args": ["query"] }
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,10 @@ Key facts:
|
||||
- The budget is a target rather than a hard cap: a page that would finish within about four times it is printed whole, because a second command costs the agent far more than the lines the cut would have saved
|
||||
- The render leads with the page's main content and puts navigation, sidebar, and footer after it, so the budget is spent on what was asked for rather than on menus
|
||||
- Benchmarked at roughly 45x fewer tokens than reading raw HTML, with per-task numbers at https://github.com/only-cli/benchmarks
|
||||
- Works on any mostly-static website; tuned shortcuts ship for Hacker News, Reddit, GitHub, X, LinkedIn (public guest views), DuckDuckGo, Bing, Stack Overflow (via its Atom feeds and the Stack Exchange API), Yahoo Finance (quotes, history, markets), Wikipedia (articles, search, and other language editions), and the AWS, Google Cloud, and Microsoft Learn documentation sites (guides, CLI reference, and search)
|
||||
- Works on any mostly-static website; tuned shortcuts ship for Hacker News, Reddit, GitHub, X, LinkedIn (public guest views), DuckDuckGo, Bing, Stack Overflow (via its Atom feeds and the Stack Exchange API), Yahoo Finance (quotes, history, markets), Wikipedia (articles, search, and other language editions), the AWS, Google Cloud, and Microsoft Learn documentation sites (guides, CLI reference, and search), and the language documentation for Python, JavaScript (MDN), Node.js, Ruby, Go, Rust, Java, PHP, TypeScript, C and C++ (cppreference), and .NET (the Microsoft Learn API browser)
|
||||
- JSON APIs render like pages: an endpoint that answers with JSON becomes one numbered item per record, with the fields that differ between items kept and the ones every item shares stated once, so a search endpoint reads like a results page for a few hundred tokens
|
||||
- A page that comes back with no readable text (JavaScript-only, a consent wall, a bot challenge) prints one line on stderr and exits 2, rather than reporting an empty render as a success. `--json` carries the same verdict as an `empty` field, so a caller can tell "nothing on this page" from "oc could not read this page" and fall back to a browser only when it is worth it
|
||||
- A shortcut is `oc <site> <verb> [args]`: `oc hn top`, `oc reddit sub ClaudeAI`, `oc gh repo only-cli oc`, `oc ddg search claude code cli`, `oc learn doc azure/aks/what-is-aks`. Name the site by its short name, bare name, or domain (`oc hn`, `oc ycombinator`, `oc news.ycombinator.com`), the last argument takes every word after it so a query needs no quoting, and `oc sites` lists every site with its verbs. A shortcut resolves to a URL and then behaves exactly like `oc open <url>`
|
||||
- A shortcut is `oc <site> <verb> [args]`: `oc hn top`, `oc reddit sub ClaudeAI`, `oc gh repo only-cli oc`, `oc ddg search claude code cli`, `oc learn doc azure/aks/what-is-aks`, `oc py library json`. Name the site by its short name, bare name, or domain (`oc hn`, `oc ycombinator`, `oc news.ycombinator.com`), the last argument takes every word after it so a query needs no quoting, and `oc sites` lists every site with its verbs. A shortcut resolves to a URL and then behaves exactly like `oc open <url>`
|
||||
- X profiles and individual posts read without a login (about 390 and 260 tokens); X search, explore, and hashtag pages do not, and oc reports the block instead of guessing
|
||||
- Outbound fetches honor `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` (and their lowercase forms), so oc works in a sandbox whose only route to the network is a proxy. An https target is tunneled with CONNECT and its certificate is still verified, credentials in the proxy URL reach the proxy and nothing else, and private or locally unresolvable targets stay refused. `ALL_PROXY` is not read
|
||||
- Requests impersonate Chrome, so pages that block plain scripts often still work
|
||||
|
||||
@@ -23,15 +23,16 @@ None of these except `open`/`do`/`raw <url>` fetch anything; they replay the pag
|
||||
|
||||
## Site shortcuts
|
||||
|
||||
`oc <site> <verb> [args]` resolves to a URL and then behaves exactly like `open` on it, so it costs the same and reads the same. It saves guessing a URL shape and, on a few sites, points at the feed or public API that answers without a login.
|
||||
`oc <site> <verb> [args]` resolves to a URL and then behaves exactly like `open` on it, so it costs the same and reads the same. It saves guessing a URL shape and, on a few sites, points at the feed or public API that answers without a login. `search` on `py`, `node`, and `ruby` ranks the docs' own index locally, and on `mdn` asks the site's API; each prints a normal numbered result page.
|
||||
|
||||
```
|
||||
oc hn top oc reddit sub ClaudeAI oc gh repo only-cli oc
|
||||
oc wiki article Eiffel Tower oc wiki search anthropic oc wiki lang de Berlin
|
||||
oc ddg search claude code oc so question 231767 oc learn doc azure/aks/what-is-aks
|
||||
oc py library json oc mdn js Array/map oc node api fs
|
||||
```
|
||||
|
||||
Sites: `hn`, `reddit`, `gh`, `x`, `linkedin`, `ddg`, `bing`, `so`, `finance`, `yt`, `aws`, `gcp`, `learn`, `wiki`. Name one by short name, bare name, or domain (`oc hn`, `oc ycombinator`, `oc news.ycombinator.com`). The last argument takes every word after it, so a query or title needs no quoting. `oc sites` lists every site with its verbs, which is cheaper than guessing one.
|
||||
Sites: `hn`, `reddit`, `gh`, `x`, `linkedin`, `ddg`, `bing`, `so`, `finance`, `yt`, `aws`, `gcp`, `learn`, `wiki`, `py`, `mdn`, `node`, `ruby`, `go`, `rust`, `java`, `php`, `cpp`, `ts`. Name one by short name, bare name, or domain (`oc hn`, `oc ycombinator`, `oc news.ycombinator.com`). The last argument takes every word after it, so a query or title needs no quoting. `oc sites` lists every site with its verbs, which is cheaper than guessing one.
|
||||
|
||||
Prefer a shortcut over a hand-built URL when one exists for the site, and prefer `oc wiki article <title>` over a search when you already know the article's name.
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* JSON search API backend. Some sites render search results only in the
|
||||
* browser but run their search behind a public JSON endpoint the results
|
||||
* page calls (MDN's /api/v1/search). The site definition names that endpoint
|
||||
* and which fields of the response hold the result list, each result's
|
||||
* title, URL, and snippet; the ranked answer the site already computed
|
||||
* becomes the same synthetic results page a Sphinx search produces, riding
|
||||
* the normal distill and render path, so `do <n>` follows a result and
|
||||
* nothing else is new. Unlike the Sphinx backend nothing is fetched but the
|
||||
* one response, so there is no cache to keep.
|
||||
*/
|
||||
|
||||
import { fetchPage } from './fetch.js';
|
||||
import { escapeHTML } from './sphinx.js';
|
||||
|
||||
const MAX_RESULTS = 20;
|
||||
|
||||
// Response fields are named by dot path ('metadata.total.value'), so a
|
||||
// definition can reach into whatever shape a site's API answers with.
|
||||
const pick = (obj, path) =>
|
||||
String(path).split('.').reduce((o, key) => (o == null ? undefined : o[key]), obj);
|
||||
|
||||
/**
|
||||
* The result list becomes a small HTML page, the same move sphinx.js makes
|
||||
* and for the same reason: numbered results, followable with `do <n>`,
|
||||
* saved as session state. Result URLs are often paths ('/en-US/docs/...'),
|
||||
* so they resolve against the endpoint they came from.
|
||||
* @param {{results?: string, fields?: Record<string, string>, total?: string}} def
|
||||
* @param {string} query
|
||||
* @param {any} data - the endpoint's parsed JSON response
|
||||
* @param {string} apiURL - the URL the response came from
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resultsToHTML(def, query, data, apiURL) {
|
||||
const host = new URL(apiURL).host;
|
||||
const fields = def.fields ?? {};
|
||||
const list = pick(data, def.results ?? 'results');
|
||||
const items = (Array.isArray(list) ? list : []).slice(0, MAX_RESULTS).map((item) => {
|
||||
const href = new URL(String(pick(item, fields.url ?? 'url') ?? ''), apiURL).href;
|
||||
const title = String(pick(item, fields.title ?? 'title') ?? href);
|
||||
const text = fields.text ? String(pick(item, fields.text) ?? '').trim() : '';
|
||||
return `<li><a href="${escapeHTML(href)}">${escapeHTML(title)}</a>`
|
||||
+ `${text ? ` ${escapeHTML(text)}` : ''}</li>`;
|
||||
});
|
||||
const total = Number(def.total ? pick(data, def.total) : NaN);
|
||||
const count = Number.isFinite(total) && total >= items.length ? total : items.length;
|
||||
const summary = items.length
|
||||
? `${count} page${count === 1 ? '' : 's'} match, ranked by the site's own search`
|
||||
+ `${count > items.length ? `, top ${items.length} shown` : ''}:`
|
||||
: `nothing in the site's own search matches; try fewer or different words`;
|
||||
return `<html><head><title>${escapeHTML(host)} search: ${escapeHTML(query)}</title></head><body><main>`
|
||||
+ `<p>${summary}</p>`
|
||||
+ (items.length ? `<ol>${items.join('')}</ol>` : '')
|
||||
+ `</main></body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search one site through its JSON endpoint. Returns the synthetic results
|
||||
* page plus the URL the session should remember: the site's human search
|
||||
* page when the definition names one, so session listings read sensibly.
|
||||
* @param {{api: string, page?: string} & Parameters<typeof resultsToHTML>[0]} def
|
||||
* @param {string} query
|
||||
*/
|
||||
export async function apiSearch(def, query) {
|
||||
if (!query.trim()) throw new Error('usage: search <query>');
|
||||
const q = encodeURIComponent(query);
|
||||
const apiURL = def.api.replaceAll('{query}', q);
|
||||
const { url: finalURL, html: body } = await fetchPage(apiURL);
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(body);
|
||||
} catch {
|
||||
throw new Error(`the search API at ${apiURL} did not answer with JSON`);
|
||||
}
|
||||
return {
|
||||
url: (def.page ?? def.api).replaceAll('{query}', q),
|
||||
html: resultsToHTML(def, query, data, finalURL),
|
||||
via: 'api',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Day cache for the big static files a local search ranks: a Sphinx site's
|
||||
* searchindex.js, the Node.js docs' all.json. Each is megabytes over the
|
||||
* wire but rebuilds at most a few times a day, and a stale result list still
|
||||
* links to live pages, so a day-old copy is a fair trade against moving the
|
||||
* file again on every search.
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { extname, join } from 'node:path';
|
||||
import { fetchPage } from './fetch.js';
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The file at `url`, parsed, from the disk cache while it is fresh and from
|
||||
* the network otherwise. One directory per backend, one file per host, kept
|
||||
* under the URL's own extension so the cache directory reads plainly. The
|
||||
* file is parsed before it is written, so a block page or an error never
|
||||
* poisons the cache, and a cache that cannot be written costs nothing but
|
||||
* the refetch, the same policy session state follows.
|
||||
* @param {string} kind - cache subdirectory, one per backend ('sphinx')
|
||||
* @param {string} url
|
||||
* @param {(text: string) => any} parse - throws on anything but the real file
|
||||
* @returns {Promise<{data: any, via: 'cache'|'network'}>}
|
||||
*/
|
||||
export async function cachedFile(kind, url, parse) {
|
||||
const dir = join(process.env.OC_HOME ?? join(homedir(), '.only-cli'), kind);
|
||||
const file = join(dir, `${new URL(url).host}${extname(new URL(url).pathname)}`);
|
||||
try {
|
||||
if (Date.now() - statSync(file).mtimeMs < CACHE_TTL_MS) {
|
||||
return { data: parse(readFileSync(file, 'utf8')), via: 'cache' };
|
||||
}
|
||||
} catch {}
|
||||
const { html } = await fetchPage(url);
|
||||
const data = parse(html);
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(file, html);
|
||||
} catch {}
|
||||
return { data, via: 'network' };
|
||||
}
|
||||
+40
-2
@@ -5,6 +5,10 @@ import { fetchPage } from './fetch.js';
|
||||
import { distill, toMarkdown, toHTML } from './distill.js';
|
||||
import { render, estimateTokens, contentTokens, contentFailure, MIN_CONTENT } from './render.js';
|
||||
import { resolveSite, listSites } from './sites.js';
|
||||
import { sphinxSearch } from './sphinx.js';
|
||||
import { nodeSearch } from './nodedocs.js';
|
||||
import { rdocSearch } from './rdoc.js';
|
||||
import { apiSearch } from './apisearch.js';
|
||||
import * as act from './act.js';
|
||||
import { DEFAULT_SESSION, assertSafeName, clearSession, loadSession, saveSession, sessionFromPage } from './session.js';
|
||||
import { authFailure, sessionExpiredMessage } from './auth.js';
|
||||
@@ -170,11 +174,18 @@ async function main() {
|
||||
// A first word that is not a command may still be a site oc ships a
|
||||
// definition for, and a shortcut is only ever a URL, so it resolves to one
|
||||
// here and the rest of this function never learns it was not typed.
|
||||
let search = null;
|
||||
if (!COMMANDS.has(command)) {
|
||||
const site = resolveSite(command, args);
|
||||
if (!site) throw new Error(`unknown command '${command}', run oc --help`);
|
||||
args = [site.url];
|
||||
command = 'open';
|
||||
// Only a search shape resolves with a query; a URL shape never has one.
|
||||
if (site.query != null) {
|
||||
search = site;
|
||||
command = 'search';
|
||||
} else {
|
||||
args = [site.url];
|
||||
command = 'open';
|
||||
}
|
||||
}
|
||||
|
||||
const sessionName = assertSafeName(values.session || DEFAULT_SESSION);
|
||||
@@ -320,6 +331,33 @@ async function main() {
|
||||
if (failure) noContent(finalUrl, failure);
|
||||
return;
|
||||
}
|
||||
case 'search': {
|
||||
// A search oc runs itself: a site's static index or docs corpus is
|
||||
// fetched (or read back from its day cache) and ranked here, a JSON
|
||||
// search API is asked directly. Either way the result list rides the
|
||||
// exact `open` path: distilled, rendered, remembered, so `do <n>`
|
||||
// follows a result. Only the list is ever printed; the index, corpus,
|
||||
// and response stay out of context.
|
||||
const t0 = performance.now();
|
||||
const local = { sphinx: sphinxSearch, nodedoc: nodeSearch, rdoc: rdocSearch };
|
||||
const kind = Object.keys(local).find((k) => search[k]);
|
||||
const { url, html, via } = kind
|
||||
? await local[kind](search[kind], search.query)
|
||||
: await apiSearch(search.api, search.query);
|
||||
const page = distill(html, url);
|
||||
if (values.json) {
|
||||
remember(page, sessionName);
|
||||
console.log(JSON.stringify({ ...page, empty: false }));
|
||||
return;
|
||||
}
|
||||
const { text, stats } = render(page, { budget: asked || 500 });
|
||||
remember(page, sessionName, stats.next);
|
||||
console.log(text);
|
||||
if (verbose) {
|
||||
console.error(`~${stats.tokens} tokens, results via ${via}, ${Math.round(performance.now() - t0)}ms`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'read': return console.log(act.read(Number(args[0]), { session: sessionName, budget: asked || 2000 }));
|
||||
case 'next': return console.log(act.next({ session: sessionName, budget: asked || 500 }));
|
||||
case 'find': return console.log(act.find(args.join(' '), { session: sessionName, budget: asked || 500 }));
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Node.js docs search backend. nodejs.org has no search results page at all:
|
||||
* the site's search box is a JavaScript modal asking a third-party service,
|
||||
* so there is nothing for oc to fetch or call directly. But the API docs
|
||||
* publish their entire reference as one static JSON file, all.json, much the
|
||||
* way a Sphinx site publishes its search index, so `search` ranks that file
|
||||
* locally: every module, class, method, property, and event heading becomes
|
||||
* a result linking to its own anchor. The file is ~8MB (~1MB over the wire)
|
||||
* and static, so it shares the Sphinx backend's day cache and, like the
|
||||
* index, is never printed: what reaches the agent is the ranked list only.
|
||||
*/
|
||||
|
||||
import { cachedFile } from './cache.js';
|
||||
import { escapeHTML } from './sphinx.js';
|
||||
|
||||
const MAX_RESULTS = 20;
|
||||
|
||||
// The list-valued keys that hold entries with headings of their own on the
|
||||
// page. The others (params, options) describe one signature's arguments and
|
||||
// have no heading or anchor; a class's constructor rides in `signatures`.
|
||||
const CHILD_KEYS = [
|
||||
'modules', 'globals', 'miscs', 'classes', 'classMethods',
|
||||
'signatures', 'methods', 'properties', 'events',
|
||||
];
|
||||
|
||||
// What each heading's `type` is called on the results page. Anything else is
|
||||
// a property whose type field holds its value type ({number}, {boolean}).
|
||||
const KIND = {
|
||||
module: 'module', misc: 'section', global: 'global', class: 'class',
|
||||
ctor: 'constructor', classMethod: 'static method', method: 'method',
|
||||
event: 'event', property: 'property',
|
||||
};
|
||||
|
||||
// The docs derive a heading's anchor from its text the github-slugger way:
|
||||
// lowercase, drop everything but letters, digits, spaces, hyphens, and
|
||||
// underscores, then spaces become hyphens. 'fs.readFile(path[, options],
|
||||
// callback)' is #fsreadfilepath-options-callback.
|
||||
const slug = (text) =>
|
||||
text.toLowerCase().replace(/[^a-z0-9 _-]/g, '').trim().replaceAll(' ', '-');
|
||||
|
||||
/**
|
||||
* Anything that parses but is not the docs corpus (an error page served as
|
||||
* JSON, a moved file) fails here, which keeps it out of the cache too.
|
||||
* @param {string} text
|
||||
* @returns {any}
|
||||
*/
|
||||
export function parseAll(text) {
|
||||
let all;
|
||||
try {
|
||||
all = JSON.parse(text);
|
||||
} catch {
|
||||
all = null;
|
||||
}
|
||||
if (!Array.isArray(all?.modules)) throw new Error('not the Node.js docs corpus');
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the docs tree into the headings a query can hit. Only a top-level
|
||||
* entry names its page (source: 'doc/api/fs.md'); everything nested under it
|
||||
* inherits that page and contributes its own heading and anchor. A nested
|
||||
* entry whose textRaw does not name it is not a heading (a bare 'Type:
|
||||
* {number}' line under a property) and is skipped; its parent still stands.
|
||||
* A heading repeated on one page is kept once: the corpus lists some methods
|
||||
* twice for one heading, and where a page really repeats one (each stream
|
||||
* class has an Event: 'close') the rows would be indistinguishable anyway.
|
||||
* @param {any} all - parsed all.json
|
||||
* @returns {{text: string, name: string, type: string, page: string, anchor: string}[]}
|
||||
*/
|
||||
export function buildEntries(all) {
|
||||
const entries = [];
|
||||
const seen = new Set();
|
||||
const add = (node, page, top) => {
|
||||
const text = String(node?.textRaw ?? '').replaceAll('`', '').trim();
|
||||
const name = String(node?.name ?? '');
|
||||
const type = String(node?.type ?? '');
|
||||
const heading = text && name
|
||||
&& (top || text.toLowerCase().includes(name.toLowerCase()));
|
||||
if (heading && !seen.has(`${page}#${text}`)) {
|
||||
seen.add(`${page}#${text}`);
|
||||
// A module or section heading is the page's own title, so its entry
|
||||
// links to the page top; every other heading has an anchor worth keeping.
|
||||
const anchor = ['module', 'misc', 'global'].includes(type) ? '' : slug(text);
|
||||
entries.push({ text, name, type, page, anchor });
|
||||
}
|
||||
for (const key of CHILD_KEYS) {
|
||||
for (const child of node?.[key] ?? []) add(child, page, false);
|
||||
}
|
||||
};
|
||||
for (const key of CHILD_KEYS) {
|
||||
for (const node of all?.[key] ?? []) {
|
||||
const page = String(node?.source ?? '')
|
||||
.replace(/^doc\/api\//, '').replace(/\.md$/, '');
|
||||
if (page) add(node, `${page}.html`, true);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank the headings against a query. A word that is an entry's own name (the
|
||||
* bare symbol: 'readFile') weighs most, a whole word of its heading next, a
|
||||
* substring of it least, and an entry must match every word before any-word
|
||||
* matching kicks in, the same policy the Sphinx backend follows.
|
||||
* @param {ReturnType<typeof buildEntries>} entries
|
||||
* @param {string} query
|
||||
*/
|
||||
export function searchEntries(entries, query) {
|
||||
const words = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))];
|
||||
const scored = [];
|
||||
for (const e of entries) {
|
||||
const nameL = e.name.toLowerCase();
|
||||
const textL = e.text.toLowerCase();
|
||||
const tokens = new Set(textL.split(/[^a-z0-9_]+/));
|
||||
let score = 0;
|
||||
let hit = 0;
|
||||
for (const word of words) {
|
||||
const s = nameL === word ? 10 : tokens.has(word) ? 5 : textL.includes(word) ? 2 : 0;
|
||||
if (s) {
|
||||
score += s;
|
||||
hit += 1;
|
||||
}
|
||||
}
|
||||
if (hit) scored.push({ e, score, hit });
|
||||
}
|
||||
let hits = scored.filter((s) => s.hit === words.length);
|
||||
const partial = !hits.length && words.length > 1 && scored.length > 0;
|
||||
if (partial) hits = scored;
|
||||
// Among equal scores the shorter heading is the plainer API, so it leads.
|
||||
hits.sort((a, b) => b.score - a.score
|
||||
|| a.e.text.length - b.e.text.length
|
||||
|| a.e.text.localeCompare(b.e.text));
|
||||
return { words, partial, total: hits.length, hits: hits.slice(0, MAX_RESULTS).map((s) => s.e) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The result list becomes the same small synthetic page the other search
|
||||
* backends emit, so it distills, renders, numbers, and remembers like any
|
||||
* fetched page and `do <n>` follows a result.
|
||||
* @param {string} base
|
||||
* @param {string} query
|
||||
* @param {ReturnType<typeof searchEntries>} found
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resultsToHTML(base, query, found) {
|
||||
const host = new URL(base).host;
|
||||
const items = found.hits.map((e) => {
|
||||
const href = new URL(e.anchor ? `${e.page}#${e.anchor}` : e.page, base).href;
|
||||
return `<li><a href="${escapeHTML(href)}">${escapeHTML(e.text)}</a>`
|
||||
+ ` ${escapeHTML(KIND[e.type] ?? 'property')}, in ${escapeHTML(e.page.replace(/\.html$/, ''))}</li>`;
|
||||
});
|
||||
const partial = found.partial ? '; no heading matches every word, so these match some' : '';
|
||||
const summary = items.length
|
||||
? `${found.total} heading${found.total === 1 ? '' : 's'} match in the docs' own reference,`
|
||||
+ ` ranked locally${found.total > MAX_RESULTS ? `, top ${MAX_RESULTS} shown` : ''}${partial}:`
|
||||
: `nothing in the docs' own reference matches; try fewer or different words`;
|
||||
return `<html><head><title>${escapeHTML(host)} search: ${escapeHTML(query)}</title></head><body><main>`
|
||||
+ `<p>${summary}</p>`
|
||||
+ (items.length ? `<ol>${items.join('')}</ol>` : '')
|
||||
+ `</main></body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the Node.js API docs. The site has no human search URL to remember,
|
||||
* so the session keeps the docs index, the page a reader would start from.
|
||||
* @param {string} base - docs root ending in '/', e.g. https://nodejs.org/api/
|
||||
* @param {string} query
|
||||
*/
|
||||
export async function nodeSearch(base, query) {
|
||||
if (!query.trim()) throw new Error('usage: search <query>');
|
||||
const { data, via } = await cachedFile('nodedoc', new URL('all.json', base).href, parseAll);
|
||||
return {
|
||||
url: new URL('index.html', base).href,
|
||||
html: resultsToHTML(base, query, searchEntries(buildEntries(data), query)),
|
||||
via,
|
||||
};
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* RDoc search backend. The Ruby docs (docs.ruby-lang.org) are built with
|
||||
* RDoc, which like Sphinx has no search server: the generated site ships its
|
||||
* whole search index as one static file, js/search_index.js, and matches in
|
||||
* the visitor's browser. So `search` ranks that file locally: every class,
|
||||
* module, method, and guide page in the index becomes a result linking to
|
||||
* its own anchor. The file is ~3.4MB (~560KB over the wire) and static, so
|
||||
* it lives in the same day cache the other local backends use, and what
|
||||
* reaches the agent is the ranked result list only.
|
||||
*/
|
||||
|
||||
import { cachedFile } from './cache.js';
|
||||
import { escapeHTML } from './sphinx.js';
|
||||
import { searchEntries } from './nodedocs.js';
|
||||
|
||||
const MAX_RESULTS = 20;
|
||||
|
||||
/**
|
||||
* The index file is `var search_data = {...}`: JSON behind one assignment
|
||||
* for the browser's benefit. Anything that does not parse that way, or that
|
||||
* lacks the info rows, is not an RDoc index, which on a wrong or moved URL
|
||||
* is the honest error, and it keeps a block page out of the cache too.
|
||||
* @param {string} js
|
||||
* @returns {any}
|
||||
*/
|
||||
export function parseRdocIndex(js) {
|
||||
const start = js.indexOf('=');
|
||||
if (start >= 0) {
|
||||
try {
|
||||
const data = JSON.parse(js.slice(start + 1));
|
||||
if (Array.isArray(data?.index?.info)) return data;
|
||||
} catch {}
|
||||
}
|
||||
throw new Error('not an RDoc search index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the index's info rows into the entries the shared ranker scores.
|
||||
* A row is [name, namespace, path, params, snippet]; the path's own anchor
|
||||
* says what the row is, so 'dig' in 'Array' with anchor method-i-dig reads
|
||||
* back as the heading a rubyist expects, Array#dig(*args). Snippets stay
|
||||
* behind: matching on them would rank prose over the symbol asked for.
|
||||
* @param {any} data - parsed search_index.js
|
||||
* @returns {{text: string, name: string, kind: string, path: string}[]}
|
||||
*/
|
||||
export function buildRdocEntries(data) {
|
||||
return data.index.info.map(([name, namespace, path, params]) => {
|
||||
const p = String(path ?? '');
|
||||
const kind = p.includes('#method-c-') ? 'class method'
|
||||
: p.includes('#method-i-') ? 'method'
|
||||
: /^[A-Z]/.test(String(name)) ? 'class' : 'page';
|
||||
const text = kind === 'class method' ? `${namespace}.${name}${params}`
|
||||
: kind === 'method' ? `${namespace}#${name}${params}`
|
||||
: namespace ? `${namespace}::${name}` : String(name ?? '');
|
||||
return { text, name: String(name ?? ''), kind, path: p };
|
||||
}).filter((e) => e.text && e.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* The result list becomes the same small synthetic page the other search
|
||||
* backends emit, so it distills, renders, numbers, and remembers like any
|
||||
* fetched page and `do <n>` follows a result.
|
||||
* @param {string} base
|
||||
* @param {string} query
|
||||
* @param {ReturnType<typeof searchEntries>} found
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resultsToHTML(base, query, found) {
|
||||
const host = new URL(base).host;
|
||||
const items = found.hits.map((e) =>
|
||||
`<li><a href="${escapeHTML(new URL(e.path, base).href)}">${escapeHTML(e.text)}</a>`
|
||||
+ ` ${escapeHTML(e.kind)}</li>`);
|
||||
const partial = found.partial ? '; no entry matches every word, so these match some' : '';
|
||||
const summary = items.length
|
||||
? `${found.total} entr${found.total === 1 ? 'y matches' : 'ies match'} in the docs' own index,`
|
||||
+ ` ranked locally${found.total > MAX_RESULTS ? `, top ${MAX_RESULTS} shown` : ''}${partial}:`
|
||||
: `nothing in the docs' own index matches; try fewer or different words`;
|
||||
return `<html><head><title>${escapeHTML(host)} search: ${escapeHTML(query)}</title></head><body><main>`
|
||||
+ `<p>${summary}</p>`
|
||||
+ (items.length ? `<ol>${items.join('')}</ol>` : '')
|
||||
+ `</main></body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search one RDoc site. The site has no search URL of its own to remember,
|
||||
* so the session keeps the docs root, the page a reader would start from.
|
||||
* @param {string} base - docs root ending in '/', e.g. https://docs.ruby-lang.org/en/3.4/
|
||||
* @param {string} query
|
||||
*/
|
||||
export async function rdocSearch(base, query) {
|
||||
if (!query.trim()) throw new Error('usage: search <query>');
|
||||
const { data, via } = await cachedFile('rdoc', new URL('js/search_index.js', base).href, parseRdocIndex);
|
||||
return {
|
||||
url: new URL('index.html', base).href,
|
||||
html: resultsToHTML(base, query, searchEntries(buildRdocEntries(data), query)),
|
||||
via,
|
||||
};
|
||||
}
|
||||
+30
-5
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* Site shortcuts. `clis/*.json` names the URLs on a site worth reaching
|
||||
* directly, so `oc hn item 4711` gets there without the agent knowing that
|
||||
* Hacker News spells it /item?id=. A shortcut is only ever a URL: it resolves
|
||||
* to one and hands off to the same fetch and render path `oc open` uses, so
|
||||
* nothing here can change what a page costs or how it reads.
|
||||
* Hacker News spells it /item?id=. A shortcut is almost always a URL: it
|
||||
* resolves to one and hands off to the same fetch and render path `oc open`
|
||||
* uses, so nothing here can change what a page costs or how it reads. The
|
||||
* other shapes are searches cli.js runs itself and renders like any other
|
||||
* page: `sphinx` and `rdoc`, for docs sites whose search only exists as a
|
||||
* static index file, `nodedoc`, for the Node.js API docs, which ship their
|
||||
* reference the same way, and `api`, for a site whose search answers as
|
||||
* JSON.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
@@ -22,12 +27,20 @@ const ALIASES = {
|
||||
finance: 'finance.yahoo.com',
|
||||
twitter: 'x.com',
|
||||
aws: 'docs.aws.amazon.com',
|
||||
py: 'docs.python.org',
|
||||
mdn: 'developer.mozilla.org',
|
||||
node: 'nodejs.org',
|
||||
rust: 'doc.rust-lang.org',
|
||||
java: 'docs.oracle.com',
|
||||
ruby: 'docs.ruby-lang.org',
|
||||
cpp: 'en.cppreference.com',
|
||||
ts: 'typescriptlang.org',
|
||||
gcp: 'cloud.google.com',
|
||||
learn: 'learn.microsoft.com',
|
||||
wiki: 'wikipedia.org',
|
||||
};
|
||||
|
||||
/** @typedef {{open: string, args?: string[]}} Shortcut */
|
||||
/** @typedef {{open?: string, sphinx?: string, nodedoc?: string, rdoc?: string, api?: string, page?: string, results?: string, fields?: Record<string, string>, total?: string, args?: string[]}} Shortcut */
|
||||
/** @typedef {{domain: string, commands: Record<string, Shortcut>}} Site */
|
||||
|
||||
/** @type {Map<string, Site>|null} */
|
||||
@@ -84,7 +97,7 @@ const verbs = (site) =>
|
||||
* instead, since the agent has the right site and only needs the verb list.
|
||||
* @param {string} name
|
||||
* @param {string[]} args
|
||||
* @returns {{url: string, domain: string, command: string}|null}
|
||||
* @returns {{url?: string, sphinx?: string, nodedoc?: string, rdoc?: string, api?: Shortcut, query?: string, domain: string, command: string}|null}
|
||||
*/
|
||||
export function resolveSite(name, args) {
|
||||
const site = sites().get(name.toLowerCase());
|
||||
@@ -102,6 +115,18 @@ export function resolveSite(name, args) {
|
||||
// separate words ('oc ddg search claude code cli') works unquoted.
|
||||
const values = need.map((_, i) =>
|
||||
i === need.length - 1 ? rest.slice(i).join(' ') : rest[i]);
|
||||
// A search oc runs itself has no page URL to build: the query is handed
|
||||
// back whole for cli.js to run against the site's own search. The local
|
||||
// backends need only their docs root; the API shape needs its whole
|
||||
// definition, since it names the endpoint and the response fields.
|
||||
for (const kind of ['sphinx', 'nodedoc', 'rdoc']) {
|
||||
if (def[kind]) {
|
||||
return { [kind]: def[kind], query: values[values.length - 1] ?? '', domain: site.domain, command: verb };
|
||||
}
|
||||
}
|
||||
if (def.api) {
|
||||
return { api: def, query: values[values.length - 1] ?? '', domain: site.domain, command: verb };
|
||||
}
|
||||
const url = need.reduce(
|
||||
(open, arg, i) => open.replaceAll(`{${arg}}`, encode(values[i], def.open, arg)),
|
||||
def.open);
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Sphinx search backend. A Sphinx-built documentation site (docs.python.org,
|
||||
* most Read the Docs projects) has no search server: its search page ships
|
||||
* the site's entire full-text index as one static file, searchindex.js, and
|
||||
* ranks matches in the visitor's browser. oc can run the same ranking here,
|
||||
* so `search` on such a site answers from the site's own index instead of a
|
||||
* third-party engine. The index is big (docs.python.org's is ~4MB, ~900KB
|
||||
* over the wire) but static, so it is cached on disk for a day and never
|
||||
* printed: what reaches the agent is only the ranked result list.
|
||||
*/
|
||||
|
||||
import { cachedFile } from './cache.js';
|
||||
|
||||
const MAX_RESULTS = 20;
|
||||
|
||||
/**
|
||||
* The index file is `Search.setIndex({...})`: JSON wrapped in one function
|
||||
* call for the browser's benefit. Anything that does not parse that way is
|
||||
* not a Sphinx index, which on a wrong or moved URL is the honest error.
|
||||
* @param {string} js
|
||||
* @returns {any}
|
||||
*/
|
||||
export function parseIndex(js) {
|
||||
const start = js.indexOf('(');
|
||||
const end = js.lastIndexOf(')');
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
return JSON.parse(js.slice(start + 1, end));
|
||||
} catch {}
|
||||
}
|
||||
throw new Error('not a Sphinx search index');
|
||||
}
|
||||
|
||||
// terms and titleterms store a bare number when a word appears in one
|
||||
// document and an array when it appears in several.
|
||||
const docsFor = (table, word) => {
|
||||
const hit = table?.[word];
|
||||
return hit == null ? null : Array.isArray(hit) ? hit : [hit];
|
||||
};
|
||||
|
||||
/**
|
||||
* Sphinx stems words before indexing ('threading' is stored as 'thread'), so
|
||||
* an exact lookup misses common query spellings. Rather than shipping the
|
||||
* Porter stemmer, try the word with common suffixes stripped, and only then
|
||||
* a prefix scan: an index key that extends the word, or that the word
|
||||
* extends, counts at reduced weight.
|
||||
* @param {Record<string, number|number[]>} table
|
||||
* @param {string} word
|
||||
* @returns {{docs: number[], exact: boolean}|null}
|
||||
*/
|
||||
function lookup(table, word) {
|
||||
const exact = docsFor(table, word);
|
||||
if (exact) return { docs: exact, exact: true };
|
||||
for (const suffix of ['ing', 'ed', 'es', 's', 'e']) {
|
||||
if (word.length - suffix.length >= 3 && word.endsWith(suffix)) {
|
||||
const hit = docsFor(table, word.slice(0, -suffix.length));
|
||||
if (hit) return { docs: hit, exact: false };
|
||||
}
|
||||
}
|
||||
if (word.length >= 4) {
|
||||
const docs = new Set();
|
||||
for (const key of Object.keys(table ?? {})) {
|
||||
if (key.length >= 4 && (key.startsWith(word) || word.startsWith(key))) {
|
||||
for (const d of docsFor(table, key)) docs.add(d);
|
||||
}
|
||||
}
|
||||
if (docs.size) return { docs: [...docs], exact: false };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An exact object hit ('json.dumps', or just 'dumps') beats any full-text
|
||||
* rank: the index maps the symbol straight to its anchor on the page, so it
|
||||
* goes at the top as a direct link. Only single-word queries can be symbols.
|
||||
* @param {any} index
|
||||
* @param {string} query
|
||||
*/
|
||||
function objectHits(index, query) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q || q.includes(' ')) return [];
|
||||
const hits = [];
|
||||
for (const [prefix, entries] of Object.entries(index.objects ?? {})) {
|
||||
for (const [doc, typeIdx, priority, anchor, name] of entries) {
|
||||
const full = prefix ? `${prefix}.${name}` : name;
|
||||
if (full.toLowerCase() !== q && name.toLowerCase() !== q) continue;
|
||||
hits.push({
|
||||
name: full,
|
||||
type: index.objnames?.[typeIdx]?.[2] ?? '',
|
||||
doc,
|
||||
anchor: anchor === '' ? full : anchor,
|
||||
priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
return hits.sort((a, b) => a.priority - b.priority).slice(0, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank the index against a query the way the site's own search page would:
|
||||
* a document must match every word, a title hit weighs far more than a body
|
||||
* hit, and only when nothing matches every word does any-word matching kick
|
||||
* in, and then the result page says so.
|
||||
* @param {any} index
|
||||
* @param {string} query
|
||||
*/
|
||||
export function searchIndex(index, query) {
|
||||
const words = [...new Set(
|
||||
query.toLowerCase().split(/\s+/)
|
||||
.map((w) => w.replace(/^[^\w.]+|[^\w.]+$/g, ''))
|
||||
.filter(Boolean))];
|
||||
const scores = new Map();
|
||||
const matched = new Map();
|
||||
for (const word of words) {
|
||||
const perDoc = new Map();
|
||||
const body = lookup(index.terms, word);
|
||||
if (body) for (const d of body.docs) perDoc.set(d, body.exact ? 5 : 2);
|
||||
const title = lookup(index.titleterms, word);
|
||||
if (title) for (const d of title.docs) perDoc.set(d, (perDoc.get(d) ?? 0) + (title.exact ? 15 : 5));
|
||||
for (const [d, score] of perDoc) {
|
||||
scores.set(d, (scores.get(d) ?? 0) + score);
|
||||
matched.set(d, (matched.get(d) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
let docs = [...scores.keys()].filter((d) => matched.get(d) === words.length);
|
||||
const partial = !docs.length && words.length > 1 && scores.size > 0;
|
||||
if (partial) docs = [...scores.keys()];
|
||||
docs.sort((a, b) =>
|
||||
scores.get(b) - scores.get(a)
|
||||
|| String(index.titles[a]).localeCompare(String(index.titles[b])));
|
||||
return {
|
||||
words,
|
||||
partial,
|
||||
total: docs.length,
|
||||
objects: objectHits(index, query),
|
||||
docs: docs.slice(0, MAX_RESULTS).map((d) => ({
|
||||
doc: d,
|
||||
title: plainTitle(index.titles[d]) || index.docnames[d],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Titles in the index arrive as the HTML of the page's <h1>, markup and all
|
||||
// (docs.python.org wraps module names in <code> spans), so they are flattened
|
||||
// to text before they are placed on the results page. A title is index data
|
||||
// from the network, so the walk keeps only what stands outside a bracket: no
|
||||
// '<' or '>' can survive it, even from a tag the title never closes. A
|
||||
// literal angle bracket in a real title arrives as an entity, so nothing
|
||||
// legitimate is lost.
|
||||
const plainTitle = (t) => {
|
||||
let text = '';
|
||||
let inTag = false;
|
||||
for (const ch of String(t)) {
|
||||
if (ch === '<') inTag = true;
|
||||
else if (ch === '>') inTag = false;
|
||||
else if (!inTag) text += ch;
|
||||
}
|
||||
return text.trim();
|
||||
};
|
||||
|
||||
export const escapeHTML = (s) => String(s)
|
||||
.replaceAll('&', '&').replaceAll('<', '<')
|
||||
.replaceAll('>', '>').replaceAll('"', '"');
|
||||
|
||||
/**
|
||||
* The result list becomes a small HTML page and rides the same distill and
|
||||
* render path a fetched page does. That is what makes results numbered,
|
||||
* followable with `do <n>`, and saved as session state, with nothing new for
|
||||
* an agent to learn.
|
||||
* @param {string} base
|
||||
* @param {string} query
|
||||
* @param {ReturnType<typeof searchIndex>} found
|
||||
* @param {any} index
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resultsToHTML(base, query, found, index) {
|
||||
const host = new URL(base).host;
|
||||
const pageURL = (doc) => new URL(`${index.docnames[doc]}.html`, base).href;
|
||||
const items = found.objects.map((o) =>
|
||||
`<li><a href="${escapeHTML(`${pageURL(o.doc)}#${o.anchor}`)}">${escapeHTML(o.name)}</a>`
|
||||
+ ` ${escapeHTML(o.type)}, in ${escapeHTML(plainTitle(index.titles[o.doc]) || index.docnames[o.doc])}</li>`);
|
||||
for (const r of found.docs) {
|
||||
items.push(`<li><a href="${escapeHTML(pageURL(r.doc))}">${escapeHTML(r.title)}</a></li>`);
|
||||
}
|
||||
const partial = found.partial ? '; no page matches every word, so these match some' : '';
|
||||
const summary = items.length
|
||||
? `${found.total} page${found.total === 1 ? '' : 's'} match in the site's own search index,`
|
||||
+ ` ranked locally${found.total > MAX_RESULTS ? `, top ${MAX_RESULTS} shown` : ''}${partial}:`
|
||||
: `nothing in the site's own search index matches; try fewer or different words`;
|
||||
return `<html><head><title>${escapeHTML(host)} search: ${escapeHTML(query)}</title></head><body><main>`
|
||||
+ `<p>${summary}</p>`
|
||||
+ (items.length ? `<ol>${items.join('')}</ol>` : '')
|
||||
+ `</main></body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search one Sphinx site. Returns the synthetic results page plus the URL the
|
||||
* session should remember: the site's human search URL, so the state reads
|
||||
* sensibly in `oc session` listings and error messages.
|
||||
* @param {string} base - site root ending in '/', e.g. https://docs.python.org/3/
|
||||
* @param {string} query
|
||||
*/
|
||||
export async function sphinxSearch(base, query) {
|
||||
if (!query.trim()) throw new Error('usage: search <query>');
|
||||
const { data: index, via } = await cachedFile('sphinx', new URL('searchindex.js', base).href, parseIndex);
|
||||
return {
|
||||
url: new URL(`search.html?q=${encodeURIComponent(query)}`, base).href,
|
||||
html: resultsToHTML(base, query, searchIndex(index, query), index),
|
||||
via,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { resultsToHTML } = await import('../src/apisearch.js');
|
||||
|
||||
// The MDN-shaped definition oc ships, minus the endpoint itself: which
|
||||
// response fields hold the list, and what each result calls its parts.
|
||||
const DEF = {
|
||||
results: 'documents',
|
||||
fields: { title: 'title', url: 'mdn_url', text: 'summary' },
|
||||
total: 'metadata.total.value',
|
||||
};
|
||||
const API = 'https://developer.mozilla.org/api/v1/search?q=map';
|
||||
|
||||
// A miniature /api/v1/search answer: a relative URL, a nested total, and a
|
||||
// title that would be markup if it were ever trusted.
|
||||
const DATA = {
|
||||
documents: [
|
||||
{ mdn_url: '/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map', title: 'Array.prototype.map()', summary: 'Creates a new array from results of a callback.' },
|
||||
{ mdn_url: '/en-US/docs/Web/API/Map', title: 'Map <script>alert(1)</script>', summary: 'Holds key-value pairs.' },
|
||||
],
|
||||
metadata: { total: { value: 2696 } },
|
||||
};
|
||||
|
||||
test('results map through the named fields and resolve against the endpoint', () => {
|
||||
const html = resultsToHTML(DEF, 'map', DATA, API);
|
||||
assert.match(html, /href="https:\/\/developer\.mozilla\.org\/en-US\/docs\/Web\/JavaScript\/Reference\/Global_Objects\/Array\/map"/);
|
||||
assert.match(html, /Array\.prototype\.map\(\)/);
|
||||
assert.match(html, /Creates a new array/);
|
||||
});
|
||||
|
||||
test('the site total is reported, and result count is what the page shows', () => {
|
||||
assert.match(resultsToHTML(DEF, 'map', DATA, API), /2696 pages match, ranked by the site's own search, top 2 shown:/);
|
||||
});
|
||||
|
||||
test('a title is response data, never markup on the results page', () => {
|
||||
const html = resultsToHTML(DEF, 'map', DATA, API);
|
||||
assert.doesNotMatch(html, /<script/);
|
||||
assert.match(html, /Map <script>/);
|
||||
});
|
||||
|
||||
test('a response with nothing in it renders an honest empty page, not an error', () => {
|
||||
const html = resultsToHTML(DEF, 'zzqqxx', { documents: [], metadata: { total: { value: 0 } } }, API);
|
||||
assert.match(html, /nothing in the site's own search matches/);
|
||||
assert.doesNotMatch(html, /<ol>/);
|
||||
});
|
||||
|
||||
test('a response missing the fields a definition names still renders a page', () => {
|
||||
// A site that reshapes its API answer should cost a bad result list, not a
|
||||
// crash: no list means the empty page, a result with no title falls back
|
||||
// to its URL.
|
||||
assert.match(resultsToHTML(DEF, 'map', { unrelated: true }, API), /nothing in the site's own search/);
|
||||
const html = resultsToHTML(DEF, 'map', { documents: [{ mdn_url: '/en-US/docs/Web/API/Map' }], metadata: {} }, API);
|
||||
assert.match(html, />https:\/\/developer\.mozilla\.org\/en-US\/docs\/Web\/API\/Map</);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { parseAll, buildEntries, searchEntries, resultsToHTML } = await import('../src/nodedocs.js');
|
||||
|
||||
const BASE = 'https://nodejs.org/api/';
|
||||
|
||||
// A miniature all.json: one module page with nested methods, classes,
|
||||
// properties, and events, plus a class hoisted to the top level the way
|
||||
// globals.md's classes are. Shapes copied from the real corpus, including
|
||||
// the parts that must not become results: a signature's parameter list, and
|
||||
// a property whose textRaw is its value type rather than a heading.
|
||||
const ALL = {
|
||||
modules: [{
|
||||
textRaw: 'File system', name: 'fs', type: 'module', source: 'doc/api/fs.md',
|
||||
methods: [
|
||||
{
|
||||
textRaw: '`fs.readFile(path[, options], callback)`', name: 'readFile', type: 'method',
|
||||
signatures: [{ params: [{ textRaw: '`path` {string|Buffer|URL}', name: 'path' }] }],
|
||||
},
|
||||
{ textRaw: '`fs.readFileSync(path[, options])`', name: 'readFileSync', type: 'method' },
|
||||
],
|
||||
classes: [
|
||||
{
|
||||
textRaw: 'Class: `fs.ReadStream`', name: 'fs.ReadStream', type: 'class',
|
||||
events: [{ textRaw: "Event: `'close'`", name: 'close', type: 'event' }],
|
||||
properties: [
|
||||
{ textRaw: '`readStream.bytesRead`', name: 'bytesRead', type: 'number' },
|
||||
{ textRaw: 'Type: {boolean}', name: 'pending', type: 'boolean' },
|
||||
],
|
||||
},
|
||||
{
|
||||
textRaw: 'Class: `fs.WriteStream`', name: 'fs.WriteStream', type: 'class',
|
||||
events: [{ textRaw: "Event: `'close'`", name: 'close', type: 'event' }],
|
||||
},
|
||||
],
|
||||
}],
|
||||
classes: [{
|
||||
textRaw: 'Class: `AbortController` <b>bold</b>', name: 'AbortController', type: 'class',
|
||||
source: 'doc/api/globals.md',
|
||||
}],
|
||||
};
|
||||
|
||||
test('only the docs corpus parses, so an error page never enters the cache', () => {
|
||||
assert.deepEqual(parseAll('{"modules": []}'), { modules: [] });
|
||||
assert.throws(() => parseAll('<html>blocked</html>'), /not the Node\.js docs corpus/);
|
||||
assert.throws(() => parseAll('{"error": "rate limited"}'), /not the Node\.js docs corpus/);
|
||||
});
|
||||
|
||||
test('nested headings inherit their page and carry the anchor the docs use', () => {
|
||||
const entries = buildEntries(ALL);
|
||||
const readFile = entries.find((e) => e.name === 'readFile');
|
||||
assert.equal(readFile.page, 'fs.html');
|
||||
assert.equal(readFile.anchor, 'fsreadfilepath-options-callback');
|
||||
assert.equal(readFile.text, 'fs.readFile(path[, options], callback)');
|
||||
assert.equal(entries.find((e) => e.name === 'fs.ReadStream').anchor, 'class-fsreadstream');
|
||||
assert.equal(entries.find((e) => e.name === 'bytesRead').anchor, 'readstreambytesread');
|
||||
});
|
||||
|
||||
test('a module heading is its page title, so its entry links to the page top', () => {
|
||||
const fs = buildEntries(ALL).find((e) => e.type === 'module');
|
||||
assert.equal(fs.page, 'fs.html');
|
||||
assert.equal(fs.anchor, '');
|
||||
});
|
||||
|
||||
test('a heading repeated on one page is kept once, not listed as twins', () => {
|
||||
const closes = buildEntries(ALL).filter((e) => e.name === 'close');
|
||||
assert.deepEqual(closes.map((e) => e.anchor), ['event-close']);
|
||||
});
|
||||
|
||||
test('parameter lists and Type: lines are not headings, so they are not results', () => {
|
||||
const entries = buildEntries(ALL);
|
||||
assert.equal(entries.find((e) => e.name === 'path'), undefined);
|
||||
assert.equal(entries.find((e) => e.name === 'pending'), undefined);
|
||||
});
|
||||
|
||||
test('a word that is a symbol name outranks the heading that merely contains it', () => {
|
||||
const found = searchEntries(buildEntries(ALL), 'readFile');
|
||||
assert.equal(found.hits[0].name, 'readFile');
|
||||
assert.ok(found.hits.some((e) => e.name === 'readFileSync'));
|
||||
assert.equal(found.partial, false);
|
||||
});
|
||||
|
||||
test('every word must match, and when none can, the page says the match is loose', () => {
|
||||
const strict = searchEntries(buildEntries(ALL), 'readFile close');
|
||||
assert.equal(strict.partial, true);
|
||||
assert.ok(strict.total > 0);
|
||||
assert.match(resultsToHTML(BASE, 'readFile close', strict), /no heading matches every word/);
|
||||
});
|
||||
|
||||
test('results link into the live docs and name their kind and page', () => {
|
||||
const html = resultsToHTML(BASE, 'readfile', searchEntries(buildEntries(ALL), 'readfile'));
|
||||
assert.match(html, /href="https:\/\/nodejs\.org\/api\/fs\.html#fsreadfilepath-options-callback"/);
|
||||
assert.match(html, /method, in fs</);
|
||||
assert.match(html, /headings match in the docs' own reference, ranked locally:/);
|
||||
});
|
||||
|
||||
test('a heading is corpus data, never markup on the results page', () => {
|
||||
const html = resultsToHTML(BASE, 'abortcontroller', searchEntries(buildEntries(ALL), 'abortcontroller'));
|
||||
assert.doesNotMatch(html, /<b>/);
|
||||
assert.match(html, /Class: AbortController <b>/);
|
||||
assert.match(html, /#class-abortcontroller-bboldb"/);
|
||||
});
|
||||
|
||||
test('nothing matching renders an honest empty page, not an error', () => {
|
||||
const html = resultsToHTML(BASE, 'zzqqxx', searchEntries(buildEntries(ALL), 'zzqqxx'));
|
||||
assert.match(html, /nothing in the docs' own reference matches/);
|
||||
assert.doesNotMatch(html, /<ol>/);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { parseRdocIndex, buildRdocEntries, resultsToHTML } = await import('../src/rdoc.js');
|
||||
const { searchEntries } = await import('../src/nodedocs.js');
|
||||
|
||||
const BASE = 'https://docs.ruby-lang.org/en/3.4/';
|
||||
|
||||
// A miniature search_index.js: a class page, instance and class methods, a
|
||||
// name shared across classes, a guide page, and a snippet that would be
|
||||
// markup if it were ever trusted. Rows are [name, namespace, path, params,
|
||||
// snippet], the shape RDoc generates.
|
||||
const INDEX = `var search_data = ${JSON.stringify({
|
||||
index: {
|
||||
searchIndex: ['array', 'dig', 'dig', 'new', 'each_slice', 'contributing', 'evil'],
|
||||
longSearchIndex: ['array', 'array::dig', 'hash::dig', 'array::new', 'array::each_slice', 'contributing', 'evil'],
|
||||
info: [
|
||||
['Array', '', 'Array.html', '', '<p>An Array is an ordered collection.'],
|
||||
['dig', 'Array', 'Array.html#method-i-dig', '(*args)', '<p>Finds the object in nested objects.'],
|
||||
['dig', 'Hash', 'Hash.html#method-i-dig', '(*args)', '<p>Finds the object in nested objects.'],
|
||||
['new', 'Array', 'Array.html#method-c-new', '(size, default)', '<p>Returns a new array.'],
|
||||
['each_slice', 'Array', 'Array.html#method-i-each_slice', '(n)', '<p>Iterates in slices.'],
|
||||
['contributing', '', 'contributing_md.html', '', '<p>How to contribute.'],
|
||||
['evil<script>alert(1)</script>', 'Array', 'Array.html#method-i-evil', '', ''],
|
||||
],
|
||||
},
|
||||
})}`;
|
||||
|
||||
const entries = () => buildRdocEntries(parseRdocIndex(INDEX));
|
||||
|
||||
test('only an RDoc index parses, so an error page never enters the cache', () => {
|
||||
assert.ok(parseRdocIndex(INDEX).index.info.length > 0);
|
||||
assert.throws(() => parseRdocIndex('<html>blocked</html>'), /not an RDoc search index/);
|
||||
assert.throws(() => parseRdocIndex('var search_data = {"unrelated": true}'), /not an RDoc search index/);
|
||||
});
|
||||
|
||||
test('rows read back as the headings a rubyist expects', () => {
|
||||
const all = entries();
|
||||
assert.equal(all.find((e) => e.path.includes('method-i-dig') && e.text.startsWith('Array')).text, 'Array#dig(*args)');
|
||||
assert.equal(all.find((e) => e.path.includes('method-c-new')).text, 'Array.new(size, default)');
|
||||
assert.equal(all.find((e) => e.path === 'Array.html').text, 'Array');
|
||||
assert.equal(all.find((e) => e.path === 'contributing_md.html').kind, 'page');
|
||||
});
|
||||
|
||||
test('a symbol query finds its methods across classes, exact name first', () => {
|
||||
const found = searchEntries(entries(), 'dig');
|
||||
assert.equal(found.total, 2);
|
||||
assert.deepEqual(found.hits.map((e) => e.text).sort(), ['Array#dig(*args)', 'Hash#dig(*args)']);
|
||||
assert.equal(found.partial, false);
|
||||
});
|
||||
|
||||
test('a class and method pair narrows to the one entry matching both words', () => {
|
||||
const found = searchEntries(entries(), 'array each_slice');
|
||||
assert.equal(found.total, 1);
|
||||
assert.equal(found.hits[0].text, 'Array#each_slice(n)');
|
||||
});
|
||||
|
||||
test('results link into the live docs, anchors intact, and name their kind', () => {
|
||||
const html = resultsToHTML(BASE, 'dig', searchEntries(entries(), 'dig'));
|
||||
assert.match(html, /href="https:\/\/docs\.ruby-lang\.org\/en\/3\.4\/Array\.html#method-i-dig"/);
|
||||
assert.match(html, /Array#dig\(\*args\)<\/a> method/);
|
||||
assert.match(html, /2 entries match in the docs' own index, ranked locally:/);
|
||||
});
|
||||
|
||||
test('an index row is data, never markup on the results page', () => {
|
||||
const html = resultsToHTML(BASE, 'evil', searchEntries(entries(), 'evil'));
|
||||
assert.doesNotMatch(html, /<script/);
|
||||
assert.match(html, /evil<script>/);
|
||||
});
|
||||
|
||||
test('nothing matching renders an honest empty page, not an error', () => {
|
||||
const html = resultsToHTML(BASE, 'zzqqxx', searchEntries(entries(), 'zzqqxx'));
|
||||
assert.match(html, /nothing in the docs' own index matches/);
|
||||
assert.doesNotMatch(html, /<ol>/);
|
||||
});
|
||||
+67
-1
@@ -52,7 +52,12 @@ test('every shipped definition is reachable and every url template is filled', (
|
||||
for (const [name, site] of sites()) {
|
||||
for (const [verb, def] of Object.entries(site.commands)) {
|
||||
const args = (def.args ?? []).map((a) => `test-${a}`);
|
||||
const { url } = resolveSite(name, [verb, ...args]);
|
||||
const resolved = resolveSite(name, [verb, ...args]);
|
||||
// A search verb resolves to a site root or endpoint to ask, not a
|
||||
// URL: an API endpoint keeps {query} until search time, so it is
|
||||
// filled here the way apiSearch fills it before the template check.
|
||||
const url = resolved.url ?? resolved.sphinx ?? resolved.nodedoc ?? resolved.rdoc
|
||||
?? (def.args ?? []).reduce((u, a) => u.replaceAll(`{${a}}`, `test-${a}`), resolved.api?.api ?? '');
|
||||
assert.doesNotMatch(url, /[{}]/, `oc ${name} ${verb} left a template var in ${url}`);
|
||||
assert.equal(new URL(url).protocol, 'https:', `oc ${name} ${verb} is not https`);
|
||||
}
|
||||
@@ -67,3 +72,64 @@ test('oc sites lists every site once, with a verb line an agent can copy', () =>
|
||||
}
|
||||
assert.match(text, /^oc hn <verb> \(ycombinator, news\.ycombinator\.com\): top \| new \| item <id>/m);
|
||||
});
|
||||
|
||||
test('language docs shortcuts resolve, and a doc path keeps its slashes', () => {
|
||||
assert.equal(
|
||||
resolveSite('py', ['library', 'json']).url,
|
||||
'https://docs.python.org/3/library/json.html');
|
||||
assert.equal(
|
||||
resolveSite('python', ['doc', 'reference/datamodel']).url,
|
||||
'https://docs.python.org/3/reference/datamodel.html');
|
||||
assert.equal(
|
||||
resolveSite('mdn', ['js', 'Array/map']).url,
|
||||
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map');
|
||||
assert.equal(
|
||||
resolveSite('mozilla', ['css', 'grid-template-columns']).url,
|
||||
'https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-columns');
|
||||
assert.equal(
|
||||
resolveSite('node', ['api', 'fs']).url,
|
||||
'https://nodejs.org/api/fs.html');
|
||||
const node = resolveSite('nodejs.org', ['search', 'readFile', 'options']);
|
||||
assert.equal(node.nodedoc, 'https://nodejs.org/api/');
|
||||
assert.equal(node.query, 'readFile options');
|
||||
const py = resolveSite('py', ['search', 'json', 'dumps']);
|
||||
assert.equal(py.sphinx, 'https://docs.python.org/3/');
|
||||
assert.equal(py.query, 'json dumps');
|
||||
const mdn = resolveSite('mdn', ['search', 'array', 'map']);
|
||||
assert.equal(mdn.api.api, 'https://developer.mozilla.org/api/v1/search?q={query}&locale=en-US');
|
||||
assert.equal(mdn.query, 'array map');
|
||||
});
|
||||
|
||||
test('the second wave of language docs resolves the same way', () => {
|
||||
assert.equal(resolveSite('go', ['pkg', 'net/http']).url, 'https://pkg.go.dev/net/http');
|
||||
assert.equal(
|
||||
resolveSite('go', ['search', 'json decode']).url,
|
||||
'https://pkg.go.dev/search?q=json%20decode');
|
||||
assert.equal(
|
||||
resolveSite('php', ['fn', 'array_map']).url,
|
||||
'https://www.php.net/manual-lookup.php?pattern=array_map');
|
||||
assert.equal(
|
||||
resolveSite('cpp', ['cpp', 'container/vector']).url,
|
||||
'https://en.cppreference.com/cpp/container/vector');
|
||||
assert.equal(
|
||||
resolveSite('cppreference', ['search', 'push_back']).url,
|
||||
'https://html.duckduckgo.com/html/?q=site%3Aen.cppreference.com+push_back');
|
||||
assert.equal(
|
||||
resolveSite('rust', ['std', 'vec/struct.Vec']).url,
|
||||
'https://doc.rust-lang.org/std/vec/struct.Vec.html');
|
||||
assert.equal(
|
||||
resolveSite('java', ['api', 'java.base/java/util/HashMap']).url,
|
||||
'https://docs.oracle.com/en/java/javase/26/docs/api/java.base/java/util/HashMap.html');
|
||||
assert.equal(
|
||||
resolveSite('ts', ['handbook', '2/everyday-types']).url,
|
||||
'https://www.typescriptlang.org/docs/handbook/2/everyday-types.html');
|
||||
assert.equal(
|
||||
resolveSite('learn', ['dotnet', 'system.string']).url,
|
||||
'https://learn.microsoft.com/en-us/dotnet/api/system.string');
|
||||
assert.equal(
|
||||
resolveSite('ruby', ['class', 'Array']).url,
|
||||
'https://docs.ruby-lang.org/en/3.4/Array.html');
|
||||
const ruby = resolveSite('docs.ruby-lang.org', ['search', 'each_slice']);
|
||||
assert.equal(ruby.rdoc, 'https://docs.ruby-lang.org/en/3.4/');
|
||||
assert.equal(ruby.query, 'each_slice');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { parseIndex, searchIndex, resultsToHTML } = await import('../src/sphinx.js');
|
||||
|
||||
// A miniature docs.python.org: enough shape to exercise every lookup path
|
||||
// (single-number terms, title boosts, stemmed words, object anchors) without
|
||||
// a fixture file to drift out of date.
|
||||
const INDEX = {
|
||||
docnames: ['library/json', 'library/threading', 'tutorial/appendix'],
|
||||
titles: [
|
||||
'<code class="pre">json</code> - JSON encoder and decoder',
|
||||
'threading - Thread-based parallelism',
|
||||
'Appendix',
|
||||
],
|
||||
terms: { json: 0, thread: [1, 2], socket: [2] },
|
||||
titleterms: { json: [0], thread: [1] },
|
||||
objects: { json: [[0, 3, 1, '', 'dumps']] },
|
||||
objnames: { 3: ['py', 'function', 'Python function'] },
|
||||
};
|
||||
const BASE = 'https://docs.python.org/3/';
|
||||
|
||||
test('parseIndex unwraps Search.setIndex() and refuses anything else', () => {
|
||||
assert.equal(parseIndex('Search.setIndex({"a": 1})').a, 1);
|
||||
assert.throws(() => parseIndex('<html>a block page</html>'), /not a Sphinx search index/);
|
||||
assert.throws(() => parseIndex('Search.setIndex(undefined)'), /not a Sphinx search index/);
|
||||
});
|
||||
|
||||
test('a title hit outranks body hits, and one-doc terms stored as a bare number work', () => {
|
||||
const found = searchIndex(INDEX, 'thread');
|
||||
assert.deepEqual(found.docs.map((d) => d.doc), [1, 2]);
|
||||
assert.equal(searchIndex(INDEX, 'json').docs[0].doc, 0);
|
||||
});
|
||||
|
||||
test('a word Sphinx stemmed away still matches through its stem', () => {
|
||||
// The index stores 'thread'; a query typed as English says 'threading'.
|
||||
const found = searchIndex(INDEX, 'threading');
|
||||
assert.equal(found.docs[0].doc, 1);
|
||||
});
|
||||
|
||||
test('titles are flattened to text before they reach the results page', () => {
|
||||
const found = searchIndex(INDEX, 'json');
|
||||
assert.equal(found.docs[0].title, 'json - JSON encoder and decoder');
|
||||
});
|
||||
|
||||
test('a tag the title never closes is stripped, not left standing', () => {
|
||||
// 'json encoder <script src=' has no closing '>', so a strip that requires
|
||||
// one would hand '<script' onward. No '<' may survive the flattening.
|
||||
const nested = { ...INDEX, titles: ['json encoder <script src=', ...INDEX.titles.slice(1)] };
|
||||
const found = searchIndex(nested, 'json');
|
||||
assert.equal(found.docs[0].title, 'json encoder');
|
||||
assert.doesNotMatch(resultsToHTML(BASE, 'json', found, nested), /<script/);
|
||||
});
|
||||
|
||||
test('every word must match, and when none can, any-word results say so', () => {
|
||||
// 'json' hits doc 0, 'socket' hits doc 2, nothing hits both.
|
||||
const found = searchIndex(INDEX, 'json socket');
|
||||
assert.equal(found.partial, true);
|
||||
const html = resultsToHTML(BASE, 'json socket', found, INDEX);
|
||||
assert.match(html, /no page matches every word/);
|
||||
});
|
||||
|
||||
test('an exact symbol query becomes a direct link to its anchor', () => {
|
||||
const found = searchIndex(INDEX, 'json.dumps');
|
||||
assert.equal(found.objects.length, 1);
|
||||
const html = resultsToHTML(BASE, 'json.dumps', found, INDEX);
|
||||
assert.match(html, /href="https:\/\/docs\.python\.org\/3\/library\/json\.html#json\.dumps"/);
|
||||
assert.match(html, /Python function/);
|
||||
});
|
||||
|
||||
test('no matches renders an honest empty page, not an error', () => {
|
||||
const html = resultsToHTML(BASE, 'zzqqxx', searchIndex(INDEX, 'zzqqxx'), INDEX);
|
||||
assert.match(html, /nothing in the site's own search index matches/);
|
||||
assert.doesNotMatch(html, /<ol>/);
|
||||
});
|
||||
Reference in New Issue
Block a user