|
13 | 13 | if (typeof window === 'undefined') { |
14 | 14 | return; |
15 | 15 | } |
16 | | - // All config data lives in `llms_config.json` file. |
17 | | - const CONFIG_URL = '/scripts/llms_config.json'; |
18 | | - |
19 | | - const state = { |
20 | | - // When loadConfig() fetches /scripts/llms_config.json, the parsed JSON lands here. |
21 | | - config: null, |
22 | | - /* If multiple callers hit ready() before the first fetch resolves, they all share this promise instead of firing duplicate network requests. */ |
23 | | - configPromise: null, |
24 | | - // Derived once from window.location.origin, trimmed of trailing slashes. |
25 | | - siteBase: window.location ? window.location.origin.replace(/\/+$/, '') : '', |
26 | | - /* After the config loads, computeRemoteBase(config) may set this to a raw GitHub URL (pulling repository.org, repository.repo, etc.). When present, it’s the highest-priority candidate in getSlugCandidates() for finding Markdown artifacts.*/ |
27 | | - remoteBase: '', |
28 | | - }; |
29 | | - |
30 | | - // Called each time a URL is built from a file path/slug. |
31 | | - function joinUrl(base, path) { |
32 | | - const trimmedBase = (base || '').replace(/\/+$/, ''); |
33 | | - const trimmedPath = (path || '').replace(/^\/+/, ''); |
34 | | - if (!trimmedBase) { |
35 | | - return trimmedPath ? `/${trimmedPath}` : '/'; |
36 | | - } |
37 | | - return trimmedPath ? `${trimmedBase}/${trimmedPath}` : trimmedBase; |
38 | | - } |
39 | | - |
40 | | - // Removes slashes as part of slug and URL building. |
41 | | - function stripSlashes(value) { |
42 | | - return (value || '').replace(/^\/+|\/+$/g, ''); |
43 | | - } |
44 | | - |
45 | 16 | // Called by getPageSlug() to decode slightly different permutations of a path. |
46 | 17 | function normalizePathname(pathname) { |
47 | 18 | let path = decodeURIComponent(pathname || '/'); |
|
104 | 75 | return buildSlugFromPath(normalized); |
105 | 76 | } |
106 | 77 |
|
107 | | - // Uses config.repository + outputs metadata to compute a raw GitHub base URL. |
108 | | - function computeRemoteBase(config) { |
109 | | - const repository = config?.repository || {}; |
110 | | - const outputs = config?.outputs || {}; |
111 | | - const files = outputs.files || {}; |
112 | | - |
113 | | - if ( |
114 | | - repository.host === 'github' && |
115 | | - repository.org && |
116 | | - repository.repo && |
117 | | - repository.default_branch |
118 | | - ) { |
119 | | - const pagesDir = stripSlashes(files.pages_dir || 'pages'); |
120 | | - const fallbackArtifacts = joinUrl( |
121 | | - stripSlashes(outputs.public_root || 'ai'), |
122 | | - pagesDir |
123 | | - ); |
124 | | - const artifactsPath = stripSlashes( |
125 | | - repository.ai_artifacts_path || fallbackArtifacts |
126 | | - ); |
127 | | - return joinUrl( |
128 | | - `https://raw.githubusercontent.com/${repository.org}/${repository.repo}/${repository.default_branch}`, |
129 | | - artifactsPath |
130 | | - ); |
131 | | - } |
132 | | - |
133 | | - return ''; |
134 | | - } |
135 | | - |
136 | | - // Fetch `llms_config.json` once and cache both the promise and the parsed object. |
137 | | - function loadConfig() { |
138 | | - if (state.configPromise) { |
139 | | - return state.configPromise; |
140 | | - } |
141 | | - |
142 | | - if (typeof fetch !== 'function') { |
143 | | - state.configPromise = Promise.resolve(null); |
144 | | - return state.configPromise; |
145 | | - } |
146 | | - |
147 | | - state.configPromise = fetch(CONFIG_URL, { credentials: 'omit' }) |
148 | | - .then((response) => { |
149 | | - if (!response.ok) { |
150 | | - throw new Error(`Failed to load config (${response.status})`); |
151 | | - } |
152 | | - return response.json(); |
153 | | - }) |
154 | | - .then((config) => { |
155 | | - state.config = config; |
156 | | - state.remoteBase = computeRemoteBase(config); |
157 | | - return state.config; |
158 | | - }) |
159 | | - .catch((error) => { |
160 | | - console.warn('Unable to load llms_config.json', error); |
161 | | - state.config = null; |
162 | | - state.remoteBase = ''; |
163 | | - return null; |
164 | | - }); |
165 | | - |
166 | | - return state.configPromise; |
167 | | - } |
168 | | - |
169 | | - // Public entry point to ensure config is loaded before performing network operations. |
170 | | - async function ready() { |
171 | | - if (state.config || state.configPromise) { |
172 | | - return state.configPromise || state.config; |
173 | | - } |
174 | | - return loadConfig(); |
175 | | - } |
176 | | - |
177 | | - // Trigger config preload without blocking UI. |
178 | | - ready(); |
179 | | - |
180 | | - // Compute the local site-relative path for Markdown artifacts (`/ai/pages/...`). |
181 | | - function getLocalPagesBase() { |
182 | | - const config = state.config; |
183 | | - const outputs = config?.outputs || {}; |
184 | | - const files = outputs.files || {}; |
185 | | - const publicRoot = `/${stripSlashes(outputs.public_root || 'ai')}`; |
186 | | - const pagesDir = stripSlashes(files.pages_dir || 'pages'); |
187 | | - return joinUrl(publicRoot, pagesDir); |
188 | | - } |
189 | | - |
190 | | - // Preserve ordering while removing duplicates created by overlapping base URLs. |
191 | | - function dedupe(list) { |
192 | | - const seen = []; |
193 | | - list.forEach((item) => { |
194 | | - if (item && !seen.includes(item)) { |
195 | | - seen.push(item); |
196 | | - } |
197 | | - }); |
198 | | - return seen; |
199 | | - } |
200 | | - |
201 | | - // Build a prioritized list of URLs where a slug's Markdown could exist. |
202 | | - function getSlugCandidates(slug) { |
| 78 | + function getMarkdownUrl(slug) { |
203 | 79 | const normalizedSlug = (slug || 'index').toString().replace(/\.md$/i, ''); |
204 | | - const candidates = []; |
205 | | - |
206 | | - if (state.remoteBase) { |
207 | | - candidates.push(joinUrl(state.remoteBase, `${normalizedSlug}.md`)); |
208 | | - } |
209 | | - |
210 | | - const localBase = getLocalPagesBase(); |
211 | | - if (localBase) { |
212 | | - candidates.push(joinUrl(localBase, `${normalizedSlug}.md`)); |
213 | | - if (state.siteBase) { |
214 | | - candidates.push( |
215 | | - joinUrl(state.siteBase, joinUrl(localBase, `${normalizedSlug}.md`)) |
216 | | - ); |
217 | | - } |
218 | | - } |
219 | | - |
220 | | - candidates.push(joinUrl('', `ai/pages/${normalizedSlug}.md`)); |
221 | | - |
222 | | - return dedupe(candidates); |
| 80 | + const host = window.location ? window.location.host : ''; |
| 81 | + const protocol = window.location ? window.location.protocol : 'https:'; |
| 82 | + return `${protocol}//${host}/ai/pages/${normalizedSlug}.md`; |
223 | 83 | } |
224 | 84 |
|
225 | | - // Simple fetch wrapper that tolerates 404s and returns `null` instead of throwing. |
226 | | - async function fetchText(url) { |
| 85 | + const NO_MARKDOWN_MESSAGE = 'No Markdown file available.'; |
| 86 | + |
| 87 | + async function fetchMarkdown(slug) { |
| 88 | + const url = getMarkdownUrl(slug); |
227 | 89 | try { |
228 | 90 | const response = await fetch(url, { credentials: 'omit' }); |
229 | 91 | if (!response.ok) { |
230 | 92 | if (response.status === 404) { |
231 | | - return null; |
| 93 | + return { text: null, url, status: 404 }; |
232 | 94 | } |
233 | 95 | throw new Error(`HTTP ${response.status}`); |
234 | 96 | } |
235 | | - return await response.text(); |
| 97 | + const text = await response.text(); |
| 98 | + return { text, url, status: 200 }; |
236 | 99 | } catch (error) { |
237 | | - console.error('Failed to fetch text', url, error); |
238 | | - return null; |
239 | | - } |
240 | | - } |
241 | | - |
242 | | - // Walk the candidate list until a Markdown file returns successfully. |
243 | | - async function fetchSlugContent(slug) { |
244 | | - await ready(); |
245 | | - const candidates = getSlugCandidates(slug); |
246 | | - for (const url of candidates) { |
247 | | - const text = await fetchText(url); |
248 | | - if (text) { |
249 | | - return { text, url }; |
250 | | - } |
| 100 | + console.warn('Copy to LLM: unable to fetch markdown file', url, error); |
| 101 | + return { text: null, url, status: 'error' }; |
251 | 102 | } |
252 | | - return null; |
253 | 103 | } |
254 | 104 |
|
255 | | - // Same candidate iteration as `fetchSlugContent`, but pipes the first successful response into a download. |
256 | | - async function downloadSlug(slug, filename) { |
257 | | - await ready(); |
258 | | - const candidates = getSlugCandidates(slug); |
259 | | - for (const url of candidates) { |
260 | | - try { |
261 | | - const response = await fetch(url, { credentials: 'omit' }); |
262 | | - if (!response.ok) { |
263 | | - if (response.status === 404) { |
264 | | - continue; |
265 | | - } |
266 | | - throw new Error(`HTTP ${response.status}`); |
267 | | - } |
268 | | - const blob = await response.blob(); |
269 | | - const objectUrl = URL.createObjectURL(blob); |
270 | | - const link = document.createElement('a'); |
271 | | - link.href = objectUrl; |
272 | | - link.download = filename; |
273 | | - document.body.appendChild(link); |
274 | | - link.click(); |
275 | | - URL.revokeObjectURL(objectUrl); |
276 | | - link.remove(); |
277 | | - return true; |
278 | | - } catch (error) { |
279 | | - console.error('Download failed, trying next candidate', url, error); |
| 105 | + async function downloadMarkdown(slug, filename) { |
| 106 | + const url = getMarkdownUrl(slug); |
| 107 | + try { |
| 108 | + const response = await fetch(url, { credentials: 'omit' }); |
| 109 | + if (!response.ok) { |
| 110 | + return response.status === 404 ? { success: false, status: 404 } : { success: false, status: response.status }; |
280 | 111 | } |
| 112 | + const blob = await response.blob(); |
| 113 | + const objectUrl = URL.createObjectURL(blob); |
| 114 | + const link = document.createElement('a'); |
| 115 | + link.href = objectUrl; |
| 116 | + link.download = filename; |
| 117 | + document.body.appendChild(link); |
| 118 | + link.click(); |
| 119 | + URL.revokeObjectURL(objectUrl); |
| 120 | + link.remove(); |
| 121 | + return { success: true }; |
| 122 | + } catch (error) { |
| 123 | + console.warn('Copy to LLM: download failed', url, error); |
| 124 | + return { success: false, status: 'error' }; |
281 | 125 | } |
282 | | - return false; |
283 | 126 | } |
284 | 127 |
|
285 | 128 | // ---------- Analytics helpers ---------- |
|
319 | 162 |
|
320 | 163 | // ---------- Page helpers ---------- |
321 | 164 |
|
322 | | - /* If fetching Markdown fails, we fall back to scraping the rendered HTML content: '.md-content__inner .md-typeset' is the default class for <article> elements. */ |
323 | | - function getFallbackPageContent() { |
324 | | - const articleContent = document.querySelector( |
325 | | - '.md-content__inner .md-typeset' |
326 | | - ); |
327 | | - return articleContent ? articleContent.innerText.trim() : ''; |
328 | | - } |
329 | | - |
330 | 165 | // ---------- Clipboard helpers ---------- |
331 | 166 | async function copyToClipboard(text, button, eventType) { |
332 | 167 | let copied = false; |
|
575 | 410 | let copySucceeded = false; |
576 | 411 | const slug = getPageSlug(); |
577 | 412 |
|
578 | | - try { |
579 | | - const result = await fetchSlugContent(slug); |
580 | | - if (result && result.text) { |
581 | | - copySucceeded = await copyToClipboard( |
582 | | - result.text, |
583 | | - copyButton, |
584 | | - 'markdown_content' |
585 | | - ); |
586 | | - } |
587 | | - } catch (error) { |
588 | | - console.error('Copy to LLM: failed to copy markdown content', error); |
589 | | - } |
590 | | - |
591 | | - if (!copySucceeded) { |
592 | | - const fallback = getFallbackPageContent(); |
593 | | - if (fallback) { |
594 | | - try { |
595 | | - copySucceeded = await copyToClipboard( |
596 | | - fallback, |
597 | | - copyButton, |
598 | | - 'page_content' |
599 | | - ); |
600 | | - } catch (fallbackError) { |
601 | | - console.error('Copy to LLM: fallback copy failed', fallbackError); |
602 | | - } |
| 413 | + const { text, status } = await fetchMarkdown(slug); |
| 414 | + if (text) { |
| 415 | + copySucceeded = await copyToClipboard( |
| 416 | + text, |
| 417 | + copyButton, |
| 418 | + 'markdown_content' |
| 419 | + ); |
| 420 | + if (!copySucceeded) { |
| 421 | + showCopyError(copyButton); |
603 | 422 | } |
604 | | - } |
605 | | - |
606 | | - if (!copySucceeded) { |
| 423 | + } else if (status === 404) { |
| 424 | + showToast(NO_MARKDOWN_MESSAGE); |
| 425 | + } else { |
607 | 426 | showCopyError(copyButton); |
608 | 427 | } |
609 | 428 |
|
|
652 | 471 | return; |
653 | 472 | } |
654 | 473 |
|
655 | | - await ready(); |
656 | 474 | const action = item.dataset.action; |
657 | 475 | const slug = getPageSlug(); |
658 | 476 |
|
659 | 477 | // Each dropdown option maps to one of the shared helpers or a new-tab prompt. |
660 | 478 | switch (action) { |
661 | 479 | case 'download-markdown': { |
662 | 480 | trackButtonClick('download_page_markdown'); |
663 | | - const success = await downloadSlug(slug, `${slug}.md`); |
664 | | - if (!success) { |
665 | | - showCopyError(item); |
666 | | - } else { |
| 481 | + const result = await downloadMarkdown(slug, `${slug}.md`); |
| 482 | + if (result.success) { |
667 | 483 | showCopySuccess(item); |
| 484 | + } else if (result.status === 404) { |
| 485 | + showToast(NO_MARKDOWN_MESSAGE); |
| 486 | + } else { |
| 487 | + showCopyError(item); |
668 | 488 | } |
669 | 489 | break; |
670 | 490 | } |
671 | 491 | case 'open-chatgpt': { |
672 | 492 | trackButtonClick('open_chatgpt'); |
673 | | - const candidates = getSlugCandidates(slug); |
674 | | - const mdUrl = candidates.length |
675 | | - ? candidates[0] |
676 | | - : window.location.href; |
| 493 | + const mdUrl = getMarkdownUrl(slug); |
677 | 494 | const prompt = `Read ${mdUrl} so I can ask questions about it.`; |
678 | 495 | const chatGPTUrl = `https://chatgpt.com/?hints=search&q=${encodeURIComponent( |
679 | 496 | prompt |
|
683 | 500 | } |
684 | 501 | case 'open-claude': { |
685 | 502 | trackButtonClick('open_claude'); |
686 | | - const candidates = getSlugCandidates(slug); |
687 | | - const mdUrl = candidates.length |
688 | | - ? candidates[0] |
689 | | - : window.location.href; |
| 503 | + const mdUrl = getMarkdownUrl(slug); |
690 | 504 | const prompt = `Read ${mdUrl} so I can ask questions about it.`; |
691 | 505 | const claudeUrl = `https://claude.ai/new?q=${encodeURIComponent( |
692 | 506 | prompt |
|
0 commit comments