@expo/code-review-cli 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ function objectValue(value) {
2
+ return value && typeof value === "object" && !Array.isArray(value)
3
+ ? value
4
+ : null;
5
+ }
6
+ function inlineText(value) {
7
+ if (!Array.isArray(value))
8
+ return "";
9
+ return value
10
+ .map((item) => {
11
+ const object = objectValue(item);
12
+ return object && typeof object.text === "string" ? object.text : "";
13
+ })
14
+ .join("")
15
+ .trim();
16
+ }
17
+ function cleanBlock(value) {
18
+ return value.replace(/\s+/g, " ").trim();
19
+ }
20
+ function referenceTitle(identifier, references) {
21
+ if (typeof identifier !== "string")
22
+ return "";
23
+ const reference = objectValue(references?.[identifier]);
24
+ return typeof reference?.title === "string" ? reference.title : "";
25
+ }
26
+ function inlineContentText(value, references) {
27
+ if (Array.isArray(value)) {
28
+ return value.map((item) => inlineContentText(item, references)).join("");
29
+ }
30
+ const object = objectValue(value);
31
+ if (!object)
32
+ return "";
33
+ if (typeof object.text === "string")
34
+ return object.text;
35
+ if (typeof object.code === "string")
36
+ return object.code;
37
+ if (object.type === "reference")
38
+ return referenceTitle(object.identifier, references);
39
+ return Object.values(object)
40
+ .map((child) => inlineContentText(child, references))
41
+ .join("");
42
+ }
43
+ function collectReadableBlocks(value, references, output) {
44
+ if (Array.isArray(value)) {
45
+ for (const item of value)
46
+ collectReadableBlocks(item, references, output);
47
+ return;
48
+ }
49
+ const object = objectValue(value);
50
+ if (!object)
51
+ return;
52
+ if (Array.isArray(object.tokens)) {
53
+ const declaration = cleanBlock(inlineContentText(object.tokens, references));
54
+ if (declaration)
55
+ output.push(declaration);
56
+ return;
57
+ }
58
+ if (object.type === "paragraph") {
59
+ const paragraph = cleanBlock(inlineContentText(object.inlineContent, references));
60
+ if (paragraph)
61
+ output.push(paragraph);
62
+ return;
63
+ }
64
+ if (object.type === "heading" && typeof object.text === "string") {
65
+ const heading = cleanBlock(object.text);
66
+ if (heading)
67
+ output.push(heading);
68
+ return;
69
+ }
70
+ if (object.type === "codeListing" && Array.isArray(object.code)) {
71
+ const code = object.code.filter((line) => typeof line === "string").join("\n");
72
+ if (code.trim())
73
+ output.push(code.trim());
74
+ return;
75
+ }
76
+ if (typeof object.name === "string" && Array.isArray(object.content)) {
77
+ const content = [];
78
+ collectReadableBlocks(object.content, references, content);
79
+ const body = content.join("\n\n");
80
+ output.push(body ? `${object.name}: ${body}` : object.name);
81
+ return;
82
+ }
83
+ if (object.type === "aside" && Array.isArray(object.content)) {
84
+ const content = [];
85
+ collectReadableBlocks(object.content, references, content);
86
+ const label = typeof object.name === "string"
87
+ ? object.name
88
+ : typeof object.style === "string"
89
+ ? object.style
90
+ : "Note";
91
+ if (content.length > 0)
92
+ output.push(`${label}: ${content.join("\n\n")}`);
93
+ return;
94
+ }
95
+ for (const child of Object.values(object)) {
96
+ collectReadableBlocks(child, references, output);
97
+ }
98
+ }
99
+ function languageFromIdentifier(identifier) {
100
+ const language = identifier?.interfaceLanguage;
101
+ if (language === "swift")
102
+ return "swift";
103
+ if (language === "occ" || language === "objective-c")
104
+ return "objective-c";
105
+ return undefined;
106
+ }
107
+ export function extractAppleDocCPage(json, url, source = {}) {
108
+ const root = objectValue(JSON.parse(json));
109
+ const metadata = objectValue(root?.metadata);
110
+ const identifier = objectValue(root?.identifier);
111
+ const title = typeof metadata?.title === "string" ? metadata.title.trim() : "";
112
+ if (!root || !metadata || !title)
113
+ return null;
114
+ const text = [];
115
+ const abstract = inlineText(root.abstract);
116
+ if (abstract)
117
+ text.push(abstract);
118
+ const references = objectValue(root.references);
119
+ collectReadableBlocks(root.primaryContentSections, references, text);
120
+ collectReadableBlocks(root.relationshipsSections, references, text);
121
+ const links = new Set();
122
+ for (const value of Object.values(references ?? {})) {
123
+ const reference = objectValue(value);
124
+ if (!reference || typeof reference.url !== "string")
125
+ continue;
126
+ if (reference.url.startsWith("/documentation/")) {
127
+ links.add(reference.url);
128
+ }
129
+ }
130
+ const body = [...new Set(text.map(cleanBlock).filter(Boolean))].join("\n\n");
131
+ if (body.length < 40)
132
+ return null;
133
+ const platforms = Array.isArray(metadata.platforms) ? metadata.platforms : [];
134
+ const availability = platforms.flatMap((item) => {
135
+ const platform = objectValue(item);
136
+ if (!platform || typeof platform.name !== "string")
137
+ return [];
138
+ const introduced = typeof platform.introducedAt === "string" ? ` ${platform.introducedAt}` : "";
139
+ const deprecated = typeof platform.deprecatedAt === "string" ? ` (deprecated ${platform.deprecatedAt})` : "";
140
+ return [`${platform.name}${introduced}${deprecated}`];
141
+ });
142
+ const modules = Array.isArray(metadata.modules) ? metadata.modules : [];
143
+ const firstModule = objectValue(modules[0]);
144
+ const isSymbol = metadata.role === "symbol" || typeof metadata.symbolKind === "string";
145
+ return {
146
+ document: {
147
+ platform: "apple",
148
+ ...source,
149
+ title,
150
+ url,
151
+ body,
152
+ ...(typeof firstModule?.name === "string" ? { framework: firstModule.name } : {}),
153
+ ...(isSymbol ? { symbol: title } : {}),
154
+ ...(languageFromIdentifier(identifier)
155
+ ? { language: languageFromIdentifier(identifier) }
156
+ : {}),
157
+ ...(availability.length > 0 ? { availability } : {}),
158
+ },
159
+ links: [...links],
160
+ };
161
+ }
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+ import { readBodyWithLimit } from "./response.js";
3
+ const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
4
+ const BRAVE_RESPONSE_LIMIT_BYTES = 1_000_000;
5
+ const BRAVE_TIMEOUT_MS = 10_000;
6
+ const braveResponseSchema = z.object({
7
+ web: z
8
+ .object({
9
+ results: z
10
+ .array(z.object({
11
+ title: z.string().min(1).max(500),
12
+ url: z.string().url().max(2_000),
13
+ description: z.string().max(2_000).optional(),
14
+ }))
15
+ .max(50),
16
+ })
17
+ .optional(),
18
+ });
19
+ function normalizeSearchText(value) {
20
+ return (value
21
+ // oxlint-disable-next-line no-control-regex -- outbound query sanitization
22
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
23
+ .replace(/\s+/g, " ")
24
+ .trim());
25
+ }
26
+ export function buildScopedSearchQuery(query, scopes) {
27
+ const normalized = normalizeSearchText(query);
28
+ if (!normalized || normalized.length > 300) {
29
+ throw new Error("Query must contain between 1 and 300 visible characters");
30
+ }
31
+ if (scopes.length === 0 || scopes.length > 8) {
32
+ throw new Error("A documentation search requires between 1 and 8 fixed scopes");
33
+ }
34
+ const scopeExpression = scopes.length === 1
35
+ ? `site:${scopes[0]}`
36
+ : `(${scopes.map((scope) => `site:${scope}`).join(" OR ")})`;
37
+ const scopedQuery = `${scopeExpression} ${normalized}`;
38
+ if (scopedQuery.length > 400 || scopedQuery.split(/\s+/).length > 50) {
39
+ throw new Error("Scoped query exceeds Brave Search limits");
40
+ }
41
+ return scopedQuery;
42
+ }
43
+ export async function searchBrave(query, scopes, limit, apiKey, fetchImplementation = fetch) {
44
+ if (!apiKey.trim()) {
45
+ throw new Error("BRAVE_SEARCH_API_KEY is not set");
46
+ }
47
+ if (!Number.isInteger(limit) || limit < 1 || limit > 10) {
48
+ throw new Error("Search result limit must be between 1 and 10");
49
+ }
50
+ const url = new URL(BRAVE_SEARCH_ENDPOINT);
51
+ url.searchParams.set("q", buildScopedSearchQuery(query, scopes));
52
+ url.searchParams.set("count", String(limit));
53
+ url.searchParams.set("search_lang", "en");
54
+ url.searchParams.set("safesearch", "moderate");
55
+ const response = await fetchImplementation(url, {
56
+ redirect: "manual",
57
+ signal: AbortSignal.timeout(BRAVE_TIMEOUT_MS),
58
+ headers: {
59
+ accept: "application/json",
60
+ "accept-encoding": "gzip",
61
+ "x-subscription-token": apiKey,
62
+ "user-agent": "review-research-mcp/0.2 (+scoped documentation search)",
63
+ },
64
+ });
65
+ if (response.status >= 300 && response.status < 400) {
66
+ throw new Error(`Brave Search unexpectedly redirected with HTTP ${response.status}`);
67
+ }
68
+ if (!response.ok) {
69
+ throw new Error(`Brave Search returned HTTP ${response.status}`);
70
+ }
71
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
72
+ if (!contentType.includes("application/json")) {
73
+ throw new Error(`Brave Search returned unsupported content type: ${contentType || "missing"}`);
74
+ }
75
+ const parsed = braveResponseSchema.parse(JSON.parse(await readBodyWithLimit(response, BRAVE_RESPONSE_LIMIT_BYTES)));
76
+ return parsed.web?.results ?? [];
77
+ }
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ // @ref LLP 0013#one-package-two-binaries [implements] — the package's second binary owns serve/update dispatch
3
+ // @ref LLP 0013#search-fetch-and-optional-index-boundary [implements] — review-facing serve and operator-only update stay separate
4
+ import { parseArgs } from "node:util";
5
+ import { defaultConfigPath } from "./paths.js";
6
+ import { runStdioServer } from "./server.js";
7
+ import { PLATFORMS } from "./types.js";
8
+ function printHelp() {
9
+ process.stdout.write(`review-research-mcp
10
+
11
+ Usage:
12
+ review-research-mcp [serve]
13
+ review-research-mcp serve [--index PATH]
14
+ review-research-mcp update [--config PATH] [--output PATH]
15
+ [--platform apple|android|react-native] [--max-pages NUMBER]
16
+
17
+ The serve command uses BRAVE_SEARCH_API_KEY for scoped web discovery, fetches only
18
+ allowlisted official pages, and optionally falls back to a local index. Expo-provider
19
+ searches use Expo's public documentation index. The update command is an optional
20
+ offline crawler for operator-managed fallback indexes.
21
+ `);
22
+ }
23
+ async function main() {
24
+ const [command = "serve", ...rest] = process.argv.slice(2);
25
+ if (command === "--help" || command === "-h" || command === "help") {
26
+ printHelp();
27
+ return;
28
+ }
29
+ if (command === "serve") {
30
+ if (rest.includes("--help") || rest.includes("-h")) {
31
+ printHelp();
32
+ return;
33
+ }
34
+ const { values } = parseArgs({
35
+ args: rest,
36
+ options: {
37
+ index: { type: "string" },
38
+ },
39
+ strict: true,
40
+ });
41
+ const indexPath = values.index ?? process.env.REVIEW_RESEARCH_INDEX_PATH;
42
+ await runStdioServer({
43
+ ...(indexPath ? { indexPath } : {}),
44
+ ...(process.env.BRAVE_SEARCH_API_KEY
45
+ ? { braveApiKey: process.env.BRAVE_SEARCH_API_KEY }
46
+ : {}),
47
+ });
48
+ return;
49
+ }
50
+ if (command === "update") {
51
+ if (rest.includes("--help") || rest.includes("-h")) {
52
+ printHelp();
53
+ return;
54
+ }
55
+ const { values } = parseArgs({
56
+ args: rest,
57
+ options: {
58
+ config: { type: "string" },
59
+ output: { type: "string" },
60
+ platform: { type: "string", multiple: true },
61
+ "max-pages": { type: "string" },
62
+ },
63
+ strict: true,
64
+ });
65
+ const invalidPlatform = values.platform?.find((platform) => !PLATFORMS.includes(platform));
66
+ if (invalidPlatform) {
67
+ throw new Error(`Unknown platform: ${invalidPlatform}`);
68
+ }
69
+ const maxPages = values["max-pages"] ? Number(values["max-pages"]) : undefined;
70
+ if (maxPages !== undefined && (!Number.isInteger(maxPages) || maxPages < 1)) {
71
+ throw new Error("--max-pages must be a positive integer");
72
+ }
73
+ const { updateDocumentationIndex } = await import("./crawler.js");
74
+ const result = await updateDocumentationIndex({
75
+ configPath: values.config ?? defaultConfigPath,
76
+ ...(values.output ? { outputPath: values.output } : {}),
77
+ ...(values.platform ? { platforms: values.platform } : {}),
78
+ ...(maxPages ? { maxPagesPerProvider: maxPages } : {}),
79
+ });
80
+ process.stderr.write(`${JSON.stringify(result, null, 2)}\n`);
81
+ return;
82
+ }
83
+ throw new Error(`Unknown command: ${command}`);
84
+ }
85
+ main().catch((error) => {
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ process.stderr.write(`review-research-mcp: ${message}\n`);
88
+ process.exitCode = 1;
89
+ });
@@ -0,0 +1,148 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { extractAppleDocCPage } from "./apple-docc.js";
5
+ import { fetchAllowedContent } from "./fetch-document.js";
6
+ import { chunkDocument, extractDocumentationPage } from "./html.js";
7
+ import { extractMarkdownDocumentationPage } from "./markdown.js";
8
+ import { getProvider, resolveAllowedUrl } from "./providers.js";
9
+ import { buildSearchIndex, writeSearchIndex } from "./search-index.js";
10
+ import { extractYouTrackIssue } from "./youtrack.js";
11
+ import { PLATFORMS, PROVIDERS, SOURCE_KINDS, } from "./types.js";
12
+ const sourcesConfigSchema = z.object({
13
+ output: z.string().min(1),
14
+ crawl: z.object({
15
+ maxPagesPerProvider: z.number().int().min(1).max(100_000),
16
+ maxDepth: z.number().int().min(0).max(20),
17
+ delayMs: z.number().int().min(0).max(60_000),
18
+ timeoutMs: z.number().int().min(100).max(120_000),
19
+ maxResponseBytes: z.number().int().min(1_024).max(20_000_000),
20
+ }),
21
+ sources: z
22
+ .array(z.object({
23
+ provider: z.enum(PROVIDERS),
24
+ sourceKind: z.enum(SOURCE_KINDS),
25
+ seedUrls: z.array(z.string().url()).min(1),
26
+ maxPages: z.number().int().min(1).max(100_000).optional(),
27
+ maxDepth: z.number().int().min(0).max(20).optional(),
28
+ }))
29
+ .min(1),
30
+ });
31
+ function sleep(milliseconds) {
32
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
33
+ }
34
+ async function crawlProvider(provider, source, limits) {
35
+ const queue = [];
36
+ const errors = [];
37
+ for (const seedUrl of source.seedUrls) {
38
+ try {
39
+ queue.push({ url: resolveAllowedUrl(provider, seedUrl), depth: 0 });
40
+ }
41
+ catch (error) {
42
+ const message = error instanceof Error ? error.message : String(error);
43
+ errors.push(`${seedUrl}: ${message}`);
44
+ }
45
+ }
46
+ const queued = new Set(queue.map((item) => item.url.href));
47
+ const visited = new Set();
48
+ const documents = [];
49
+ while (queue.length > 0 && visited.size < limits.maxPagesPerProvider) {
50
+ const item = queue.shift();
51
+ if (!item || visited.has(item.url.href))
52
+ continue;
53
+ visited.add(item.url.href);
54
+ try {
55
+ const content = await fetchAllowedContent(provider, item.url, limits);
56
+ const sourceMetadata = {
57
+ provider: provider.id,
58
+ sourceKind: source.sourceKind,
59
+ };
60
+ const format = provider.responseFormat(item.url);
61
+ const page = format === "docc-json"
62
+ ? extractAppleDocCPage(content, item.url.href, sourceMetadata)
63
+ : format === "markdown"
64
+ ? extractMarkdownDocumentationPage(content, item.url.href, provider.platform, sourceMetadata)
65
+ : format === "youtrack-json"
66
+ ? extractYouTrackIssue(content, item.url.href, sourceMetadata)
67
+ : extractDocumentationPage(content, item.url.href, provider.platform, sourceMetadata);
68
+ if (page) {
69
+ documents.push(page.document);
70
+ if (item.depth < limits.maxDepth) {
71
+ for (const href of page.links) {
72
+ try {
73
+ const nextUrl = resolveAllowedUrl(provider, href, item.url.href);
74
+ if (!queued.has(nextUrl.href) && !visited.has(nextUrl.href)) {
75
+ queued.add(nextUrl.href);
76
+ queue.push({ url: nextUrl, depth: item.depth + 1 });
77
+ }
78
+ }
79
+ catch {
80
+ // Off-allowlist and malformed links are intentionally ignored.
81
+ }
82
+ }
83
+ }
84
+ }
85
+ }
86
+ catch (error) {
87
+ const message = error instanceof Error ? error.message : String(error);
88
+ errors.push(`${item.url.href}: ${message}`);
89
+ }
90
+ if (limits.delayMs > 0 && queue.length > 0) {
91
+ await sleep(limits.delayMs);
92
+ }
93
+ }
94
+ return { provider: provider.id, platform: provider.platform, documents, errors };
95
+ }
96
+ export async function readSourcesConfig(configPath) {
97
+ return sourcesConfigSchema.parse(JSON.parse(await readFile(configPath, "utf8")));
98
+ }
99
+ export function resolveIndexOutputPath(configPath, configuredOutput, outputPath) {
100
+ if (outputPath)
101
+ return path.resolve(outputPath);
102
+ return path.resolve(path.dirname(path.resolve(configPath)), configuredOutput);
103
+ }
104
+ export async function updateDocumentationIndex(options) {
105
+ const absoluteConfigPath = path.resolve(options.configPath);
106
+ const config = await readSourcesConfig(absoluteConfigPath);
107
+ const selectedPlatforms = new Set(options.platforms ?? PLATFORMS);
108
+ const limits = {
109
+ ...config.crawl,
110
+ ...(options.maxPagesPerProvider ? { maxPagesPerProvider: options.maxPagesPerProvider } : {}),
111
+ };
112
+ const selectedSources = config.sources.filter((source) => selectedPlatforms.has(getProvider(source.provider).platform));
113
+ if (selectedSources.length === 0) {
114
+ throw new Error("No configured sources matched the selected platforms");
115
+ }
116
+ const crawlResults = await Promise.all(selectedSources.map((source) => {
117
+ const sourceLimits = {
118
+ ...limits,
119
+ maxPagesPerProvider: Math.min(limits.maxPagesPerProvider, source.maxPages ?? limits.maxPagesPerProvider),
120
+ maxDepth: Math.min(limits.maxDepth, source.maxDepth ?? limits.maxDepth),
121
+ };
122
+ return crawlProvider(getProvider(source.provider), source, sourceLimits);
123
+ }));
124
+ const indexedAt = new Date().toISOString();
125
+ const documents = crawlResults.flatMap((result) => result.documents);
126
+ const chunks = documents.flatMap((document) => chunkDocument(document, indexedAt));
127
+ if (chunks.length === 0) {
128
+ const details = crawlResults
129
+ .flatMap((result) => result.errors)
130
+ .slice(0, 10)
131
+ .join("\n");
132
+ throw new Error(`Index update produced no searchable content${details ? `:\n${details}` : ""}`);
133
+ }
134
+ const outputPath = resolveIndexOutputPath(absoluteConfigPath, config.output, options.outputPath);
135
+ const index = buildSearchIndex(chunks, documents.length, indexedAt);
136
+ await writeSearchIndex(outputPath, index.serialized);
137
+ return {
138
+ outputPath,
139
+ documentCount: documents.length,
140
+ chunkCount: chunks.length,
141
+ providers: crawlResults.map((result) => ({
142
+ provider: result.provider,
143
+ platform: result.platform,
144
+ documentCount: result.documents.length,
145
+ errors: result.errors,
146
+ })),
147
+ };
148
+ }
@@ -0,0 +1,95 @@
1
+ // Expo embeds this search-only key in docs.expo.dev's browser client. It cannot
2
+ // browse or mutate the index and is not a secret. Expo-provider searches send
3
+ // only the already-sanitized API/concept query.
4
+ import * as cheerio from "cheerio";
5
+ import { z } from "zod";
6
+ import { expoProvider, resolveAllowedUrl } from "./providers.js";
7
+ import { readBodyWithLimit } from "./response.js";
8
+ const EXPO_ALGOLIA_ENDPOINT = "https://qex7pb7d46-dsn.algolia.net/1/indexes/expo/query";
9
+ const EXPO_ALGOLIA_APPLICATION_ID = "QEX7PB7D46";
10
+ const EXPO_ALGOLIA_PUBLIC_SEARCH_KEY = "6652d26570e8628af4601e1d78ad456b";
11
+ const MAX_HITS = 10;
12
+ const hitSchema = z.object({
13
+ objectID: z.string().max(500),
14
+ url: z.string().url().max(2048),
15
+ content: z.string().max(50_000).nullable().optional(),
16
+ language: z.string().max(50).optional(),
17
+ hierarchy: z.record(z.string().max(50), z.string().max(10_000).nullable()).optional(),
18
+ });
19
+ const responseSchema = z.object({
20
+ hits: z.array(hitSchema).max(MAX_HITS),
21
+ });
22
+ function text(value) {
23
+ const $ = cheerio.load(`<body>${value}</body>`);
24
+ $("script,style,noscript").remove();
25
+ return $("body").text().replace(/\s+/g, " ").trim();
26
+ }
27
+ export function extractExpoAlgoliaDocuments(json) {
28
+ const response = responseSchema.parse(JSON.parse(json));
29
+ const documents = [];
30
+ const seen = new Set();
31
+ for (const hit of response.hits) {
32
+ const originalUrl = new URL(hit.url);
33
+ const canonicalUrl = resolveAllowedUrl(expoProvider, hit.url);
34
+ canonicalUrl.hash = originalUrl.hash.slice(0, 300);
35
+ const url = canonicalUrl.href;
36
+ const hierarchy = Object.entries(hit.hierarchy ?? {})
37
+ .sort(([left], [right]) => left.localeCompare(right))
38
+ .map(([, value]) => (value ? text(value) : ""))
39
+ .filter(Boolean);
40
+ const title = hierarchy.at(-1) ?? hierarchy[0];
41
+ const body = [...hierarchy, hit.content ? text(hit.content) : ""].filter(Boolean).join("\n\n");
42
+ if (!title || body.length < 20 || seen.has(hit.objectID))
43
+ continue;
44
+ seen.add(hit.objectID);
45
+ documents.push({
46
+ platform: "react-native",
47
+ provider: "expo",
48
+ sourceKind: "official-api",
49
+ title,
50
+ url,
51
+ body,
52
+ });
53
+ }
54
+ return documents;
55
+ }
56
+ async function fetchExpoAlgolia(query, limit, timeoutMs, maxResponseBytes) {
57
+ const response = await fetch(EXPO_ALGOLIA_ENDPOINT, {
58
+ method: "POST",
59
+ redirect: "error",
60
+ signal: AbortSignal.timeout(timeoutMs),
61
+ headers: {
62
+ "content-type": "application/json",
63
+ "x-algolia-api-key": EXPO_ALGOLIA_PUBLIC_SEARCH_KEY,
64
+ "x-algolia-application-id": EXPO_ALGOLIA_APPLICATION_ID,
65
+ },
66
+ body: JSON.stringify({
67
+ params: new URLSearchParams({
68
+ query,
69
+ hitsPerPage: String(limit),
70
+ page: "0",
71
+ }).toString(),
72
+ facetFilters: [["version:none", "version:latest"]],
73
+ attributesToRetrieve: ["objectID", "url", "content", "hierarchy", "language"],
74
+ attributesToHighlight: [],
75
+ }),
76
+ });
77
+ if (!response.ok)
78
+ throw new Error(`Expo Algolia returned HTTP ${response.status}`);
79
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
80
+ if (!contentType.includes("json")) {
81
+ throw new Error(`Expo Algolia returned unsupported content type: ${contentType || "missing"}`);
82
+ }
83
+ const body = await readBodyWithLimit(response, maxResponseBytes);
84
+ return extractExpoAlgoliaDocuments(body);
85
+ }
86
+ export async function searchExpoAlgolia(query, limit) {
87
+ const normalized = query.replace(/\s+/g, " ").trim();
88
+ if (!normalized || normalized.length > 300) {
89
+ throw new Error("Expo Algolia query must contain between 1 and 300 characters");
90
+ }
91
+ if (!Number.isInteger(limit) || limit < 1 || limit > 10) {
92
+ throw new Error("Expo Algolia result limit must be between 1 and 10");
93
+ }
94
+ return fetchExpoAlgolia(normalized, limit, 5000, 1_000_000);
95
+ }
@@ -0,0 +1,76 @@
1
+ import { extractAppleDocCPage } from "./apple-docc.js";
2
+ import { extractDocumentationPage } from "./html.js";
3
+ import { extractMarkdownDocumentationPage } from "./markdown.js";
4
+ import { resolveAllowedRequestUrl, resolveAllowedUrl, } from "./providers.js";
5
+ import { readBodyWithLimit } from "./response.js";
6
+ import { extractYouTrackIssue } from "./youtrack.js";
7
+ export const onDemandFetchLimits = {
8
+ maxPagesPerProvider: 10,
9
+ maxDepth: 0,
10
+ delayMs: 0,
11
+ timeoutMs: 10_000,
12
+ maxResponseBytes: 5_000_000,
13
+ };
14
+ export async function fetchAllowedContent(provider, documentUrl, limits, fetchImplementation = fetch) {
15
+ let currentUrl = resolveAllowedRequestUrl(provider, provider.requestUrl(documentUrl).href);
16
+ for (let redirectCount = 0; redirectCount <= 5; redirectCount++) {
17
+ const response = await fetchImplementation(currentUrl, {
18
+ redirect: "manual",
19
+ signal: AbortSignal.timeout(limits.timeoutMs),
20
+ headers: {
21
+ "accept-language": "en-US,en;q=0.9",
22
+ accept: (() => {
23
+ const format = provider.responseFormat(documentUrl);
24
+ if (format === "docc-json" || format === "youtrack-json") {
25
+ return "application/json";
26
+ }
27
+ if (format === "markdown") {
28
+ return "text/markdown,text/plain;q=0.9";
29
+ }
30
+ return "text/html,application/xhtml+xml;q=0.9";
31
+ })(),
32
+ "user-agent": "review-research-mcp/0.2 (+on-demand official documentation fetcher)",
33
+ },
34
+ });
35
+ if (response.status >= 300 && response.status < 400) {
36
+ const location = response.headers.get("location");
37
+ if (!location) {
38
+ throw new Error(`redirect ${response.status} did not include Location`);
39
+ }
40
+ currentUrl = resolveAllowedRequestUrl(provider, location, currentUrl.href);
41
+ continue;
42
+ }
43
+ if (!response.ok) {
44
+ throw new Error(`HTTP ${response.status}`);
45
+ }
46
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
47
+ const expectedFormat = provider.responseFormat(documentUrl);
48
+ const isExpectedType = expectedFormat === "docc-json" || expectedFormat === "youtrack-json"
49
+ ? contentType.includes("json")
50
+ : expectedFormat === "markdown"
51
+ ? contentType.includes("text/plain") || contentType.includes("text/markdown")
52
+ : contentType.includes("text/html") || contentType.includes("application/xhtml+xml");
53
+ if (!isExpectedType) {
54
+ throw new Error(`unsupported content type: ${contentType || "missing"}`);
55
+ }
56
+ return readBodyWithLimit(response, limits.maxResponseBytes);
57
+ }
58
+ throw new Error("too many redirects");
59
+ }
60
+ export async function fetchDocumentationDocument(provider, rawUrl, sourceKind, fetchImplementation = fetch) {
61
+ const documentUrl = resolveAllowedUrl(provider, rawUrl);
62
+ const content = await fetchAllowedContent(provider, documentUrl, onDemandFetchLimits, fetchImplementation);
63
+ const source = {
64
+ provider: provider.id,
65
+ sourceKind,
66
+ };
67
+ const format = provider.responseFormat(documentUrl);
68
+ const extracted = format === "docc-json"
69
+ ? extractAppleDocCPage(content, documentUrl.href, source)
70
+ : format === "markdown"
71
+ ? extractMarkdownDocumentationPage(content, documentUrl.href, provider.platform, source)
72
+ : format === "youtrack-json"
73
+ ? extractYouTrackIssue(content, documentUrl.href, source)
74
+ : extractDocumentationPage(content, documentUrl.href, provider.platform, source);
75
+ return extracted?.document ?? null;
76
+ }