@taurgis/bonsai 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +239 -0
  2. package/agents/agents/official-docs-researcher.agent.md +98 -0
  3. package/agents/instructions/web-research.instructions.md +29 -0
  4. package/agents/skills/web-research/SKILL.md +126 -0
  5. package/bin/cli.mjs +22 -0
  6. package/dist/base-command.d.ts +14 -0
  7. package/dist/base-command.js +56 -0
  8. package/dist/commands/config/base.d.ts +23 -0
  9. package/dist/commands/config/base.js +46 -0
  10. package/dist/commands/config/get.d.ts +20 -0
  11. package/dist/commands/config/get.js +48 -0
  12. package/dist/commands/config/list.d.ts +17 -0
  13. package/dist/commands/config/list.js +48 -0
  14. package/dist/commands/config/set.d.ts +31 -0
  15. package/dist/commands/config/set.js +94 -0
  16. package/dist/commands/config/unset.d.ts +21 -0
  17. package/dist/commands/config/unset.js +65 -0
  18. package/dist/commands/fetch.d.ts +38 -0
  19. package/dist/commands/fetch.js +311 -0
  20. package/dist/commands/import.d.ts +38 -0
  21. package/dist/commands/import.js +313 -0
  22. package/dist/commands/index.d.ts +35 -0
  23. package/dist/commands/index.js +306 -0
  24. package/dist/commands/inspect.d.ts +13 -0
  25. package/dist/commands/inspect.js +82 -0
  26. package/dist/commands/list.d.ts +25 -0
  27. package/dist/commands/list.js +143 -0
  28. package/dist/commands/prune.d.ts +24 -0
  29. package/dist/commands/prune.js +141 -0
  30. package/dist/commands/research/config/base.d.ts +23 -0
  31. package/dist/commands/research/config/base.js +46 -0
  32. package/dist/commands/research/config/get.d.ts +20 -0
  33. package/dist/commands/research/config/get.js +45 -0
  34. package/dist/commands/research/config/list.d.ts +17 -0
  35. package/dist/commands/research/config/list.js +42 -0
  36. package/dist/commands/research/config/set.d.ts +31 -0
  37. package/dist/commands/research/config/set.js +94 -0
  38. package/dist/commands/research/config/unset.d.ts +21 -0
  39. package/dist/commands/research/config/unset.js +59 -0
  40. package/dist/commands/research/import.d.ts +38 -0
  41. package/dist/commands/research/import.js +313 -0
  42. package/dist/commands/research/inspect.d.ts +13 -0
  43. package/dist/commands/research/inspect.js +86 -0
  44. package/dist/commands/research/list.d.ts +25 -0
  45. package/dist/commands/research/list.js +143 -0
  46. package/dist/commands/research/prune.d.ts +24 -0
  47. package/dist/commands/research/prune.js +141 -0
  48. package/dist/commands/research/search.d.ts +32 -0
  49. package/dist/commands/research/search.js +334 -0
  50. package/dist/commands/research/status.d.ts +17 -0
  51. package/dist/commands/research/status.js +108 -0
  52. package/dist/commands/research.d.ts +36 -0
  53. package/dist/commands/research.js +304 -0
  54. package/dist/commands/search.d.ts +32 -0
  55. package/dist/commands/search.js +334 -0
  56. package/dist/commands/status.d.ts +17 -0
  57. package/dist/commands/status.js +95 -0
  58. package/dist/commands.d.ts +24 -0
  59. package/dist/commands.js +24 -0
  60. package/dist/lib/config/index.d.ts +18 -0
  61. package/dist/lib/config/index.js +33 -0
  62. package/dist/lib/config/io.d.ts +8 -0
  63. package/dist/lib/config/io.js +75 -0
  64. package/dist/lib/config/resolve.d.ts +15 -0
  65. package/dist/lib/config/resolve.js +20 -0
  66. package/dist/lib/config/schema.d.ts +28 -0
  67. package/dist/lib/config/schema.js +32 -0
  68. package/dist/lib/research/artifact.d.ts +21 -0
  69. package/dist/lib/research/artifact.js +217 -0
  70. package/dist/lib/research/browser.d.ts +74 -0
  71. package/dist/lib/research/browser.js +359 -0
  72. package/dist/lib/research/cache-key.d.ts +5 -0
  73. package/dist/lib/research/cache-key.js +10 -0
  74. package/dist/lib/research/capture.d.ts +29 -0
  75. package/dist/lib/research/capture.js +181 -0
  76. package/dist/lib/research/compress.d.ts +5 -0
  77. package/dist/lib/research/compress.js +17 -0
  78. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +1 -0
  79. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +2 -0
  80. package/dist/lib/research/docs/capabilities.d.ts +43 -0
  81. package/dist/lib/research/docs/capabilities.js +9 -0
  82. package/dist/lib/research/docs/clean-dom.d.ts +4 -0
  83. package/dist/lib/research/docs/clean-dom.js +41 -0
  84. package/dist/lib/research/docs/code-blocks.d.ts +5 -0
  85. package/dist/lib/research/docs/code-blocks.js +69 -0
  86. package/dist/lib/research/docs/detect.d.ts +6 -0
  87. package/dist/lib/research/docs/detect.js +239 -0
  88. package/dist/lib/research/docs/machine-readable.d.ts +21 -0
  89. package/dist/lib/research/docs/machine-readable.js +73 -0
  90. package/dist/lib/research/docs/markdown-source.d.ts +17 -0
  91. package/dist/lib/research/docs/markdown-source.js +194 -0
  92. package/dist/lib/research/docs/quality-gates.d.ts +9 -0
  93. package/dist/lib/research/docs/quality-gates.js +65 -0
  94. package/dist/lib/research/docs/remote-search-runner.d.ts +21 -0
  95. package/dist/lib/research/docs/remote-search-runner.js +54 -0
  96. package/dist/lib/research/docs/remote-search.d.ts +14 -0
  97. package/dist/lib/research/docs/remote-search.js +101 -0
  98. package/dist/lib/research/docs/rsc.d.ts +10 -0
  99. package/dist/lib/research/docs/rsc.js +30 -0
  100. package/dist/lib/research/docs/search-index.d.ts +13 -0
  101. package/dist/lib/research/docs/search-index.js +176 -0
  102. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  103. package/dist/lib/research/docs/section-artifacts.js +75 -0
  104. package/dist/lib/research/docs/sections.d.ts +12 -0
  105. package/dist/lib/research/docs/sections.js +67 -0
  106. package/dist/lib/research/docs/source-map.d.ts +19 -0
  107. package/dist/lib/research/docs/source-map.js +87 -0
  108. package/dist/lib/research/docs/validate.d.ts +15 -0
  109. package/dist/lib/research/docs/validate.js +63 -0
  110. package/dist/lib/research/extract.d.ts +11 -0
  111. package/dist/lib/research/extract.js +83 -0
  112. package/dist/lib/research/fetcher.d.ts +29 -0
  113. package/dist/lib/research/fetcher.js +192 -0
  114. package/dist/lib/research/freshness.d.ts +21 -0
  115. package/dist/lib/research/freshness.js +82 -0
  116. package/dist/lib/research/markdown.d.ts +6 -0
  117. package/dist/lib/research/markdown.js +66 -0
  118. package/dist/lib/research/regression.d.ts +23 -0
  119. package/dist/lib/research/regression.js +93 -0
  120. package/dist/lib/research/resolve-target.d.ts +18 -0
  121. package/dist/lib/research/resolve-target.js +20 -0
  122. package/dist/lib/research/revalidate.d.ts +32 -0
  123. package/dist/lib/research/revalidate.js +152 -0
  124. package/dist/lib/research/schema.d.ts +47 -0
  125. package/dist/lib/research/schema.js +1 -0
  126. package/dist/lib/research/secret-scan.d.ts +5 -0
  127. package/dist/lib/research/secret-scan.js +37 -0
  128. package/dist/lib/research/secure-write.d.ts +16 -0
  129. package/dist/lib/research/secure-write.js +17 -0
  130. package/dist/lib/research/storage.d.ts +52 -0
  131. package/dist/lib/research/storage.js +182 -0
  132. package/dist/lib/research/store-roots.d.ts +29 -0
  133. package/dist/lib/research/store-roots.js +28 -0
  134. package/dist/lib/research/token-estimate.d.ts +5 -0
  135. package/dist/lib/research/token-estimate.js +7 -0
  136. package/dist/lib/research/url.d.ts +24 -0
  137. package/dist/lib/research/url.js +121 -0
  138. package/dist/sites/index.d.ts +4 -0
  139. package/dist/sites/index.js +28 -0
  140. package/dist/sites/salesforce/browser-search.d.ts +7 -0
  141. package/dist/sites/salesforce/browser-search.js +82 -0
  142. package/dist/sites/salesforce/coveo.d.ts +23 -0
  143. package/dist/sites/salesforce/coveo.js +161 -0
  144. package/dist/sites/salesforce/fetch-page.d.ts +6 -0
  145. package/dist/sites/salesforce/fetch-page.js +42 -0
  146. package/dist/sites/salesforce/index.d.ts +2 -0
  147. package/dist/sites/salesforce/index.js +16 -0
  148. package/dist/sites/salesforce/token.d.ts +26 -0
  149. package/dist/sites/salesforce/token.js +154 -0
  150. package/dist/sites/salesforce-developer/fetch-page.d.ts +3 -0
  151. package/dist/sites/salesforce-developer/fetch-page.js +18 -0
  152. package/dist/sites/salesforce-developer/index.d.ts +2 -0
  153. package/dist/sites/salesforce-developer/index.js +12 -0
  154. package/dist/sites/salesforce-doc-fetch.d.ts +14 -0
  155. package/dist/sites/salesforce-doc-fetch.js +321 -0
  156. package/dist/sites/types.d.ts +27 -0
  157. package/dist/sites/types.js +1 -0
  158. package/oclif.manifest.json +845 -0
  159. package/package.json +78 -0
@@ -0,0 +1,121 @@
1
+ import { isIP } from 'node:net';
2
+ /**
3
+ * Checks if a literal IPv4 address is in a safe range.
4
+ * Rejects loopback, private RFC1918, and link-local ranges.
5
+ */
6
+ export function isSafeIpv4(ip) {
7
+ const parts = ip.split('.').map(Number);
8
+ if (parts.length !== 4 || parts.some(isNaN))
9
+ return false;
10
+ const [a, b, c] = parts;
11
+ if (a === undefined || b === undefined || c === undefined)
12
+ return false;
13
+ // Loopback: 127.0.0.0/8
14
+ if (a === 127)
15
+ return false;
16
+ // Private (RFC1918): 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
17
+ if (a === 10)
18
+ return false;
19
+ if (a === 172 && b >= 16 && b <= 31)
20
+ return false;
21
+ if (a === 192 && b === 168)
22
+ return false;
23
+ // Link-local: 169.254.0.0/16
24
+ if (a === 169 && b === 254)
25
+ return false;
26
+ return true;
27
+ }
28
+ /**
29
+ * Checks if a literal IPv6 address is in a safe range.
30
+ * Rejects loopback, unspecified, link-local, and mapped unsafe IPv4 ranges.
31
+ */
32
+ export function isSafeIpv6(ip) {
33
+ const cleanIp = ip.toLowerCase().trim();
34
+ // Loopback: ::1
35
+ if (cleanIp === '::1' || cleanIp === '0:0:0:0:0:0:0:1')
36
+ return false;
37
+ // Unspecified: ::
38
+ if (cleanIp === '::' || cleanIp === '0:0:0:0:0:0:0:0')
39
+ return false;
40
+ // Link-local: fe80::/10 (fe80 to febf)
41
+ if (cleanIp.startsWith('fe8') ||
42
+ cleanIp.startsWith('fe9') ||
43
+ cleanIp.startsWith('fea') ||
44
+ cleanIp.startsWith('feb')) {
45
+ return false;
46
+ }
47
+ // IPv4-mapped IPv6: ::ffff:127.0.0.1
48
+ if (cleanIp.startsWith('::ffff:')) {
49
+ const ipv4Part = ip.slice(7);
50
+ if (isIP(ipv4Part) === 4) {
51
+ return isSafeIpv4(ipv4Part);
52
+ }
53
+ }
54
+ return true;
55
+ }
56
+ /**
57
+ * Checks if a literal IP address is a safe public IP.
58
+ * Returns false for loopback, private, link-local, or unspecified ranges.
59
+ */
60
+ export function isSafeIp(ip) {
61
+ const version = isIP(ip);
62
+ if (version === 4)
63
+ return isSafeIpv4(ip);
64
+ if (version === 6)
65
+ return isSafeIpv6(ip);
66
+ return false;
67
+ }
68
+ /**
69
+ * Validates a hostname to ensure it is not blocked (e.g. localhost or literal private IP).
70
+ */
71
+ export function validateHostname(hostname) {
72
+ const lower = hostname.toLowerCase().trim();
73
+ if (lower === 'localhost' || lower.endsWith('.localhost')) {
74
+ throw new Error(`Hostname "${hostname}" is a blocked local target.`);
75
+ }
76
+ let ipToCheck = lower;
77
+ if (ipToCheck.startsWith('[') && ipToCheck.endsWith(']')) {
78
+ ipToCheck = ipToCheck.slice(1, -1);
79
+ }
80
+ if (isIP(ipToCheck) !== 0) {
81
+ if (!isSafeIp(ipToCheck)) {
82
+ throw new Error(`IP address "${ipToCheck}" is a blocked local or private target.`);
83
+ }
84
+ }
85
+ }
86
+ /**
87
+ * Parses and normalizes a URL. Strips fragments, credentials, default ports,
88
+ * and sorts query parameters lexicographically.
89
+ */
90
+ export function normalizeUrl(input) {
91
+ let url;
92
+ try {
93
+ url = new URL(input);
94
+ }
95
+ catch (err) {
96
+ throw new Error(`Invalid URL "${input}": ${err.message}`);
97
+ }
98
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
99
+ throw new Error(`Unsupported protocol "${url.protocol}". Only http: and https: are supported.`);
100
+ }
101
+ if (url.username || url.password) {
102
+ throw new Error('URLs containing usernames or passwords are rejected.');
103
+ }
104
+ validateHostname(url.hostname);
105
+ // Clear default ports
106
+ if (url.protocol === 'https:' && url.port === '443') {
107
+ url.port = '';
108
+ }
109
+ if (url.protocol === 'http:' && url.port === '80') {
110
+ url.port = '';
111
+ }
112
+ // Strip plain anchor fragments, but PRESERVE SPA hash routes (e.g. Docsify "#/guide/intro",
113
+ // "#!/path") so client-rendered docs get a distinct, consistent cache key per page (T-28).
114
+ if (!/^#!?\//.test(url.hash)) {
115
+ url.hash = '';
116
+ }
117
+ // Sort query parameters
118
+ const params = Array.from(url.searchParams.entries()).sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
119
+ url.search = new URLSearchParams(params).toString();
120
+ return url.toString();
121
+ }
@@ -0,0 +1,4 @@
1
+ import type { SiteModule } from './types.js';
2
+ export declare const SITES: SiteModule[];
3
+ export declare function detectSite(url: string): SiteModule | undefined;
4
+ export declare function getSiteModuleById(id: string): SiteModule | undefined;
@@ -0,0 +1,28 @@
1
+ import { salesforce } from './salesforce/index.js';
2
+ import { salesforceDeveloper } from './salesforce-developer/index.js';
3
+ // The known documentation sites. A plain constant, not a registry: these ship
4
+ // with the CLI, so there is nothing to register at runtime. Sites with custom
5
+ // fetch/search behavior live in their own module (e.g. ./salesforce). Salesforce Help and
6
+ // Salesforce Developer are separate modules — different domains, different extraction.
7
+ export const SITES = [
8
+ { id: 'react', name: 'React', domains: ['react.dev', 'legacy.reactjs.org'] },
9
+ salesforce,
10
+ salesforceDeveloper,
11
+ // TanStack docs embed a GitHub "edit this page" link, so capture resolves the page's source
12
+ // Markdown (code blocks intact) and only falls back to the browser when no source is found.
13
+ // Forcing rendered here bypassed that and dropped fenced code samples (e.g. useQueries).
14
+ { id: 'tanstack', name: 'TanStack', domains: ['tanstack.com'] },
15
+ ];
16
+ export function detectSite(url) {
17
+ let hostname;
18
+ try {
19
+ hostname = new URL(url).hostname;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ return SITES.find((site) => site.domains.includes(hostname));
25
+ }
26
+ export function getSiteModuleById(id) {
27
+ return SITES.find((site) => site.id === id);
28
+ }
@@ -0,0 +1,7 @@
1
+ import type { SiteSearchResult } from '../types.js';
2
+ /**
3
+ * Searches Salesforce Help via Coveo. Reuses a cached token for a direct API call when
4
+ * possible; otherwise drives a headless browser, reads the page's own Coveo search response,
5
+ * and caches the Coveo token (intercepted from the page's Aura call) for the next run.
6
+ */
7
+ export declare function searchSalesforce(query: string, limit?: number): Promise<SiteSearchResult[]>;
@@ -0,0 +1,82 @@
1
+ import { openCdpPage, waitForLoad, ResponseCapture } from '../../lib/research/browser.js';
2
+ import { checkDnsSafety } from '../../lib/research/fetcher.js';
3
+ import { buildSearchPageUrl, extractCoveoResults, searchCoveoDirect, COVEO_SEARCH_PATH, AURA_PATH, } from './coveo.js';
4
+ import { loadToken, storeToken, isTokenValid, parseAuraToken } from './token.js';
5
+ const DEFAULT_LIMIT = 10;
6
+ const PAGE_LOAD_TIMEOUT_MS = 15_000;
7
+ const COVEO_WAIT_MS = 30_000;
8
+ const TOKEN_WAIT_MS = 5_000;
9
+ const DEBUG = Boolean(process.env.RESEARCH_DEBUG);
10
+ function dbg(message) {
11
+ if (DEBUG)
12
+ console.error(`[salesforce-search] ${message}`);
13
+ }
14
+ // Salesforce gates content behind a OneTrust cookie banner; dismissing it lets the page run.
15
+ const ACCEPT_CONSENT_EXPRESSION = "(() => { const b = document.querySelector('#onetrust-accept-btn-handler'); if (b) b.click(); })()";
16
+ /**
17
+ * Searches Salesforce Help via Coveo. Reuses a cached token for a direct API call when
18
+ * possible; otherwise drives a headless browser, reads the page's own Coveo search response,
19
+ * and caches the Coveo token (intercepted from the page's Aura call) for the next run.
20
+ */
21
+ export async function searchSalesforce(query, limit = DEFAULT_LIMIT) {
22
+ const cached = loadToken();
23
+ if (cached && isTokenValid(cached.expiresAtMs)) {
24
+ dbg('using cached token (direct Coveo call)');
25
+ const results = extractCoveoResults(await searchCoveoDirect(cached, query, limit), limit);
26
+ if (results.length)
27
+ return results;
28
+ dbg('cached token returned no results; falling back to browser');
29
+ }
30
+ return searchViaBrowser(query, limit);
31
+ }
32
+ async function searchViaBrowser(query, limit) {
33
+ await checkDnsSafety('help.salesforce.com');
34
+ const page = await openCdpPage();
35
+ try {
36
+ // No asset blocking: the search SPA must fully boot to run its query and mint its token.
37
+ const capture = new ResponseCapture(page, [
38
+ { key: 'coveo', test: ({ url }) => url.includes(COVEO_SEARCH_PATH) },
39
+ // help.salesforce.com runs on LWR (no global $A to mint a token ourselves), so we
40
+ // intercept the page's own Aura token response — the one whose body parses to a token.
41
+ {
42
+ key: 'token',
43
+ test: ({ url }) => url.includes(AURA_PATH),
44
+ accept: (body) => parseAuraToken(body) !== null,
45
+ },
46
+ ]);
47
+ await page.client.send('Page.navigate', { url: buildSearchPageUrl(query) }, page.sessionId);
48
+ await waitForLoad(page.client, page.sessionId, PAGE_LOAD_TIMEOUT_MS).catch(() => { });
49
+ await page.client
50
+ .send('Runtime.evaluate', { expression: ACCEPT_CONSENT_EXPRESSION }, page.sessionId)
51
+ .catch(() => { });
52
+ const coveoBody = await capture.waitFor('coveo', COVEO_WAIT_MS);
53
+ await cacheToken(capture);
54
+ if (!coveoBody) {
55
+ dbg('no Coveo search response captured');
56
+ return [];
57
+ }
58
+ return extractCoveoResults(parseJson(coveoBody), limit);
59
+ }
60
+ finally {
61
+ await page.close();
62
+ }
63
+ }
64
+ async function cacheToken(capture) {
65
+ const tokenBody = await capture.waitFor('token', TOKEN_WAIT_MS);
66
+ const token = tokenBody ? parseAuraToken(tokenBody) : null;
67
+ if (token) {
68
+ storeToken(token);
69
+ dbg('cached Coveo token for next run');
70
+ }
71
+ else {
72
+ dbg('no Aura token response intercepted; next run will use the browser again');
73
+ }
74
+ }
75
+ function parseJson(text) {
76
+ try {
77
+ return JSON.parse(text);
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
@@ -0,0 +1,23 @@
1
+ import type { SiteSearchResult } from '../types.js';
2
+ import { type TokenInfo } from './token.js';
3
+ export declare const ALLOWED_DOC_HOSTS: Set<string>;
4
+ export declare const SEARCH_PAGE_URL = "https://help.salesforce.com/s/search-result";
5
+ export declare const COVEO_SEARCH_PATH = "/services/apexrest/coveo/analytics/rest/search/v2";
6
+ export declare const DEFAULT_LANGUAGE = "en_US";
7
+ export declare const AURA_PATH = "/s/sfsites/aura";
8
+ export declare function isAllowedDocHost(hostname: string): boolean;
9
+ /** The search page reads its query from the URL hash, so this URL is browser-only (never normalized). */
10
+ export declare function buildSearchPageUrl(query: string, language?: string): string;
11
+ /**
12
+ * Coveo indexes Help articles under an internal `/help_doccontent` URL; rewrite it to the
13
+ * canonical `/s/articleView` page a human (or our fetcher) can actually open.
14
+ */
15
+ export declare function normalizeHelpDocContentUrl(rawUrl: string): string;
16
+ /** Maps a raw Coveo response into deduped, host-validated search results. */
17
+ export declare function extractCoveoResults(data: unknown, limit: number): SiteSearchResult[];
18
+ /**
19
+ * Direct Coveo Search API call using a cached token — the no-browser fast path.
20
+ * Tries a bearer header first, then falls back to a query-string token on auth rejection.
21
+ * Returns the parsed response body (with a `results` array) or null on failure.
22
+ */
23
+ export declare function searchCoveoDirect(tokenInfo: TokenInfo, query: string, limit: number, language?: string, timeoutMs?: number): Promise<unknown | null>;
@@ -0,0 +1,161 @@
1
+ import { COVEO_DEFAULTS } from './token.js';
2
+ export const ALLOWED_DOC_HOSTS = new Set(['help.salesforce.com', 'developer.salesforce.com']);
3
+ export const SEARCH_PAGE_URL = 'https://help.salesforce.com/s/search-result';
4
+ // Path of the XHR the search page fires; we intercept its response as a fallback.
5
+ export const COVEO_SEARCH_PATH = '/services/apexrest/coveo/analytics/rest/search/v2';
6
+ export const DEFAULT_LANGUAGE = 'en_US';
7
+ // The page mints its Coveo token via an Aura action on this path; we intercept the response
8
+ // (identified by whether its body parses to a token) to cache it for the no-browser fast path.
9
+ export const AURA_PATH = '/s/sfsites/aura';
10
+ export function isAllowedDocHost(hostname) {
11
+ return ALLOWED_DOC_HOSTS.has(hostname.toLowerCase());
12
+ }
13
+ /** The search page reads its query from the URL hash, so this URL is browser-only (never normalized). */
14
+ export function buildSearchPageUrl(query, language = DEFAULT_LANGUAGE) {
15
+ const url = new URL(SEARCH_PAGE_URL);
16
+ if (language)
17
+ url.searchParams.set('language', language);
18
+ if (query) {
19
+ url.hash = new URLSearchParams({ q: query, t: 'allResultsTab', sort: 'relevancy' }).toString();
20
+ }
21
+ return url.toString();
22
+ }
23
+ /**
24
+ * Coveo indexes Help articles under an internal `/help_doccontent` URL; rewrite it to the
25
+ * canonical `/s/articleView` page a human (or our fetcher) can actually open.
26
+ */
27
+ export function normalizeHelpDocContentUrl(rawUrl) {
28
+ let url;
29
+ try {
30
+ url = new URL(rawUrl);
31
+ }
32
+ catch {
33
+ return rawUrl;
34
+ }
35
+ if (url.hostname.toLowerCase() !== 'help.salesforce.com')
36
+ return rawUrl;
37
+ if (!url.pathname.toLowerCase().startsWith('/help_doccontent'))
38
+ return rawUrl;
39
+ const id = url.searchParams.get('id');
40
+ if (!id)
41
+ return rawUrl;
42
+ const articleUrl = new URL('https://help.salesforce.com/s/articleView');
43
+ articleUrl.searchParams.set('id', id.endsWith('.htm') ? id : `${id}.htm`);
44
+ articleUrl.searchParams.set('type', '5');
45
+ for (const key of ['release', 'language']) {
46
+ const value = url.searchParams.get(key);
47
+ if (value)
48
+ articleUrl.searchParams.set(key, value);
49
+ }
50
+ return articleUrl.toString();
51
+ }
52
+ const SKIP_URLS = new Set([
53
+ 'https://help.salesforce.com/',
54
+ 'https://help.salesforce.com/s',
55
+ 'https://help.salesforce.com/s/',
56
+ 'https://help.salesforce.com/s/login',
57
+ ]);
58
+ function resultUrl(result) {
59
+ const raw = result.raw || {};
60
+ return (result.clickUri ||
61
+ result.uri ||
62
+ raw.clickuri ||
63
+ raw.uri ||
64
+ raw.sourceurl ||
65
+ raw.document_uri ||
66
+ raw.sfurl ||
67
+ raw.sfdcurl ||
68
+ null);
69
+ }
70
+ /** Maps a raw Coveo response into deduped, host-validated search results. */
71
+ export function extractCoveoResults(data, limit) {
72
+ const results = Array.isArray(data?.results)
73
+ ? data.results
74
+ : [];
75
+ const items = [];
76
+ const seen = new Set();
77
+ for (const result of results) {
78
+ const candidate = resultUrl(result);
79
+ if (!candidate)
80
+ continue;
81
+ const url = normalizeHelpDocContentUrl(candidate);
82
+ let hostname;
83
+ try {
84
+ hostname = new URL(url).hostname;
85
+ }
86
+ catch {
87
+ continue;
88
+ }
89
+ if (!isAllowedDocHost(hostname) || SKIP_URLS.has(url) || seen.has(url))
90
+ continue;
91
+ seen.add(url);
92
+ items.push({
93
+ url,
94
+ title: result.title || result.raw?.title || url,
95
+ ...(result.excerpt ? { snippet: result.excerpt } : {}),
96
+ });
97
+ if (items.length >= limit)
98
+ break;
99
+ }
100
+ return items;
101
+ }
102
+ function buildCoveoPayload(tokenInfo, query, language, limit) {
103
+ const payload = {
104
+ q: query,
105
+ numberOfResults: limit,
106
+ firstResult: 0,
107
+ searchHub: tokenInfo.searchHub,
108
+ locale: language,
109
+ tab: 'allResultsTab',
110
+ sortCriteria: 'relevancy',
111
+ };
112
+ if (tokenInfo.filterer)
113
+ payload.aq = tokenInfo.filterer;
114
+ return payload;
115
+ }
116
+ /**
117
+ * Direct Coveo Search API call using a cached token — the no-browser fast path.
118
+ * Tries a bearer header first, then falls back to a query-string token on auth rejection.
119
+ * Returns the parsed response body (with a `results` array) or null on failure.
120
+ */
121
+ export async function searchCoveoDirect(tokenInfo, query, limit, language = DEFAULT_LANGUAGE, timeoutMs = 15_000) {
122
+ const clientBase = (tokenInfo.clientUri || COVEO_DEFAULTS.clientUri).replace(/\/$/, '');
123
+ const payload = JSON.stringify(buildCoveoPayload(tokenInfo, query, language, limit));
124
+ const attempt = async (mode) => {
125
+ const url = new URL(`${clientBase}/rest/search/v2`);
126
+ // ponytail: query-string token is a documented Coveo fallback for clients that strip the
127
+ // Authorization header. It can land in proxy logs, but this is an anonymous public search
128
+ // token (no user identity), so the exposure is acceptable; revisit if the token ever binds a user.
129
+ if (mode === 'query')
130
+ url.searchParams.set('access_token', tokenInfo.accessToken);
131
+ const headers = { 'content-type': 'application/json' };
132
+ if (mode === 'header')
133
+ headers.authorization = `Bearer ${tokenInfo.accessToken}`;
134
+ const controller = new AbortController();
135
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
136
+ try {
137
+ const response = await fetch(url, {
138
+ method: 'POST',
139
+ headers,
140
+ body: payload,
141
+ signal: controller.signal,
142
+ });
143
+ const data = response.ok ? await response.json().catch(() => null) : null;
144
+ return { status: response.status, data };
145
+ }
146
+ finally {
147
+ clearTimeout(timer);
148
+ }
149
+ };
150
+ try {
151
+ const primary = await attempt('header');
152
+ if (primary.data)
153
+ return primary.data;
154
+ if (![400, 401, 403].includes(primary.status))
155
+ return null;
156
+ return (await attempt('query')).data;
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
@@ -0,0 +1,6 @@
1
+ import type { SiteFetchResult } from '../types.js';
2
+ /**
3
+ * Fetches a help.salesforce.com article. Coveo's internal /help_doccontent URLs are rewritten
4
+ * to the canonical /s/articleView page first; the shared LWR fetcher does the rest.
5
+ */
6
+ export declare function fetchSalesforcePage(url: string): Promise<SiteFetchResult>;
@@ -0,0 +1,42 @@
1
+ import { fetchSalesforceDoc } from '../salesforce-doc-fetch.js';
2
+ import { normalizeHelpDocContentUrl } from './coveo.js';
3
+ // Containers a Help article renders into (inside shadow DOM); the shared fetcher pierces
4
+ // shadow roots to find them, falling back to the page body when none match.
5
+ const CONTENT_SELECTORS = [
6
+ 'c-hc-documentation-article',
7
+ '.markdown-content',
8
+ '.ht-body',
9
+ '.slds-rich-text-editor__output',
10
+ '.article-body',
11
+ 'article',
12
+ 'main',
13
+ '.cHCPortalTheme',
14
+ ];
15
+ /**
16
+ * Fetches a help.salesforce.com article. Coveo's internal /help_doccontent URLs are rewritten
17
+ * to the canonical /s/articleView page first; the shared LWR fetcher does the rest.
18
+ */
19
+ export async function fetchSalesforcePage(url) {
20
+ return fetchSalesforceDoc(normalizeHelpDocContentUrl(url), {
21
+ allowedHost: 'help.salesforce.com',
22
+ contentSelectors: CONTENT_SELECTORS,
23
+ removeSelectors: [
24
+ // The "Did this article solve your issue?" widget appears on every article.
25
+ 'c-hc-article-feedback',
26
+ // The left-hand docs navigation tree (table of contents). Keyed by class, not an
27
+ // aria-label, so it escapes the shared chrome filter — and it can be 100k+ chars of
28
+ // link text, which the container pick would otherwise capture as content.
29
+ '.toc-container',
30
+ // In-article navigation chrome that sits inside the article container: the collapsed
31
+ // "Show Table of Contents" control and the "You are here" breadcrumb trail.
32
+ 'c-hc-table-of-content',
33
+ '[class*="breadcrumb" i]',
34
+ // Screen-reader-only labels (visually hidden) — "You are here:", a duplicate title, toggle
35
+ // labels. innerText skips them so they don't show on screen, but the serializer captures
36
+ // hidden nodes; they are always redundant a11y text, never primary content.
37
+ '.slds-assistive-text',
38
+ '.ht-foot',
39
+ '.ht-footer',
40
+ ],
41
+ });
42
+ }
@@ -0,0 +1,2 @@
1
+ import type { SiteModule } from '../types.js';
2
+ export declare const salesforce: SiteModule;
@@ -0,0 +1,16 @@
1
+ import { fetchSalesforcePage } from './fetch-page.js';
2
+ import { searchSalesforce } from './browser-search.js';
3
+ // Scoped to help.salesforce.com — an LWR Experience site with light-DOM article content and
4
+ // Coveo (HTCommunity) search. developer.salesforce.com is intentionally NOT routed here: its
5
+ // API-reference docs render inside shadow DOM with tabbed/code content that needs a different
6
+ // extraction strategy, so it belongs in its own module when built — not bolted onto this one.
7
+ // (developer.salesforce.com URLs that appear in Help search results are still surfaced; see
8
+ // ALLOWED_DOC_HOSTS in coveo.ts.)
9
+ export const salesforce = {
10
+ id: 'salesforce',
11
+ name: 'Salesforce Help',
12
+ domains: ['help.salesforce.com'],
13
+ defaults: { rendered: true },
14
+ fetchPage: fetchSalesforcePage,
15
+ search: searchSalesforce,
16
+ };
@@ -0,0 +1,26 @@
1
+ export declare const COVEO_DEFAULTS: {
2
+ readonly organizationId: "org62salesforce";
3
+ readonly searchHub: "HTCommunity";
4
+ readonly endpointBase: "https://help.salesforce.com/services/apexrest/coveo";
5
+ readonly clientUri: "https://platform.cloud.coveo.com";
6
+ };
7
+ export interface TokenInfo {
8
+ accessToken: string;
9
+ organizationId: string;
10
+ searchHub: string;
11
+ endpointBase: string;
12
+ clientUri: string;
13
+ filterer: string | null;
14
+ expiresAtMs: number;
15
+ }
16
+ export declare function isTokenValid(expiresAtMs: number | null | undefined): boolean;
17
+ export declare function decodeJwtPayload(token: string): Record<string, unknown> | null;
18
+ export declare function getTokenExpiryMs(accessToken: string): number | null;
19
+ export declare function applyTokenExpiry(tokenInfo: TokenInfo): TokenInfo;
20
+ /**
21
+ * Parses the access token out of a Salesforce Aura `getToken` response body.
22
+ * Returns null when the payload is missing, errored, or lacks a token.
23
+ */
24
+ export declare function parseAuraToken(rawResponseBody: string): TokenInfo | null;
25
+ export declare function loadToken(): TokenInfo | null;
26
+ export declare function storeToken(tokenInfo: TokenInfo): void;