diff --git a/src/assets/icons/chatgpt.svg b/src/assets/icons/chatgpt.svg new file mode 100644 index 0000000000..cdd5b878a4 --- /dev/null +++ b/src/assets/icons/chatgpt.svg @@ -0,0 +1,3 @@ + diff --git a/src/assets/icons/claude.svg b/src/assets/icons/claude.svg new file mode 100644 index 0000000000..316f06e41a --- /dev/null +++ b/src/assets/icons/claude.svg @@ -0,0 +1,3 @@ + diff --git a/src/assets/icons/external-link.svg b/src/assets/icons/external-link.svg new file mode 100644 index 0000000000..259c6c254a --- /dev/null +++ b/src/assets/icons/external-link.svg @@ -0,0 +1,4 @@ + + diff --git a/src/components/CopyMarkdown.astro b/src/components/CopyMarkdown.astro deleted file mode 100644 index 30ba16072d..0000000000 --- a/src/components/CopyMarkdown.astro +++ /dev/null @@ -1,87 +0,0 @@ ---- -import { SITE } from '@config'; -import { Lang, Translations } from '@util/Languages'; -import { getEligibleSlugs } from '@util/mdxContent'; - -type Props = { - lang: string; -}; -const { lang } = Astro.props satisfies Props; - -const _ = Lang(lang); - -const docsPath = Astro.url.pathname.replace(/\/$/, ''); -const subfolderPrefix = SITE.subfolder.replace(/\/$/, '') + '/'; -const docsSlug = docsPath.startsWith(subfolderPrefix) - ? docsPath.slice(subfolderPrefix.length) - : null; -const eligibleSlugs = getEligibleSlugs(); -const pageMdUrl = - docsSlug && eligibleSlugs.has(docsSlug) ? docsPath + '.md' : null; -const allDocsUrl = SITE.subfolder.replace(/\/$/, '') + '/llms-full.txt'; ---- - -{ - pageMdUrl && ( -
-
- - - -
-
-
- ) -} diff --git a/src/components/OpenInLlm.astro b/src/components/OpenInLlm.astro new file mode 100644 index 0000000000..33c6e5a0b0 --- /dev/null +++ b/src/components/OpenInLlm.astro @@ -0,0 +1,90 @@ +--- +import { SITE } from '@config'; +import { Lang, Translations } from '@util/Languages'; +import { pageMarkdownUrl } from '@util/mdxContent'; +import SplitButton from './SplitButton.astro'; + +type Props = { + lang: string; +}; +const { lang } = Astro.props satisfies Props; + +const _ = Lang(lang); + +const pageMdUrl = pageMarkdownUrl(Astro.url.pathname); + +// The assistant fetches the page itself, so the prompt carries an absolute URL +// rather than the site-relative one the rest of the site links with. +const prompt = pageMdUrl + ? _(Translations.octopus_open_in_llm.prompt).replace( + '{url}', + new URL(pageMdUrl, SITE.url).href + ) + : ''; + +const assistants = [ + { + label: _(Translations.octopus_open_in_llm.open_in_claude), + href: 'https://claude.ai/new?q=' + encodeURIComponent(prompt), + icon: 'octo-llm__icon octo-llm__icon--claude', + iconEnd: 'octo-llm__icon octo-llm__icon--external', + }, + { + label: _(Translations.octopus_open_in_llm.open_in_chatgpt), + href: 'https://chatgpt.com/?hints=search&q=' + encodeURIComponent(prompt), + icon: 'octo-llm__icon octo-llm__icon--chatgpt', + iconEnd: 'octo-llm__icon octo-llm__icon--external', + }, +]; + +const [primary] = assistants; +--- + +{ + pageMdUrl && ( + + ) +} + + diff --git a/src/data/language.json b/src/data/language.json index cbf99cf725..5c88b04905 100644 --- a/src/data/language.json +++ b/src/data/language.json @@ -38,24 +38,18 @@ "en": "Copy as markdown" } }, - "octopus_copy_md": { - "label": { - "en": "Use Octopus docs with AI" + "octopus_open_in_llm": { + "open_in_claude": { + "en": "Open in Claude" }, - "copy": { - "en": "Copy this page as markdown" - }, - "copied": { - "en": "Copied!" - }, - "view_raw": { - "en": "Open this page as markdown" + "open_in_chatgpt": { + "en": "Open in ChatGPT" }, - "download_all": { - "en": "Open all docs as markdown" + "more_assistants": { + "en": "Open in another AI assistant" }, - "error": { - "en": "Copy failed" + "prompt": { + "en": "Read {url} so I can ask questions about it." } }, "post": { diff --git a/src/layouts/Default.astro b/src/layouts/Default.astro index 5ffdb2f38f..a7a561b4fd 100644 --- a/src/layouts/Default.astro +++ b/src/layouts/Default.astro @@ -21,8 +21,8 @@ import Related from '../components/Related.astro'; import ArticleJourney from '../components/ArticleJourney.astro'; import Feedback from '../components/Feedback.astro'; import EditOnGithub from '../components/EditOnGithub.astro'; -import CopyMarkdown from '../components/CopyMarkdown.astro'; import CopyAsMarkdown from '../components/CopyAsMarkdown.astro'; +import OpenInLlm from '../components/OpenInLlm.astro'; import Plausible from 'src/components/Plausible.astro'; import Footer from 'src/components/Footer.astro'; @@ -90,6 +90,7 @@ const lastUpdated = frontmatter.modDate ?? frontmatter.pubDate ?? null; lang={lang} /> +
@@ -100,7 +101,6 @@ const lastUpdated = frontmatter.modDate ?? frontmatter.pubDate ?? null; /> - { - labelEl.textContent = restoreLabel; - }, 2000); - } - if (this.liveRegion) { - // Force a content change so AT re-announces a repeated message. - this.liveRegion.textContent = ''; - setTimeout(() => { - this.liveRegion.textContent = message; - }, 50); - } - } - - // navigator.clipboard requires a secure context; falls back to - // execCommand for HTTP and older browsers. - async writeToClipboard(text) { - if ( - typeof navigator !== 'undefined' && - navigator.clipboard && - typeof navigator.clipboard.writeText === 'function' && - (typeof window === 'undefined' || window.isSecureContext !== false) - ) { - try { - await navigator.clipboard.writeText(text); - return true; - } catch (err) { - console.warn('[copy-md] navigator.clipboard failed, falling back', err); - } - } - - return this.execCommandCopyFallback(text); - } - - execCommandCopyFallback(text) { - if (typeof document === 'undefined') return false; - const ta = document.createElement('textarea'); - ta.value = text; - ta.setAttribute('readonly', ''); - ta.style.position = 'fixed'; - ta.style.top = '0'; - ta.style.left = '0'; - ta.style.opacity = '0'; - ta.style.pointerEvents = 'none'; - document.body.appendChild(ta); - ta.select(); - let ok = false; - try { - ok = document.execCommand('copy'); - } catch (err) { - console.warn('[copy-md] execCommand fallback threw', err); - ok = false; - } - document.body.removeChild(ta); - return ok; - } - - async handleCopy(btn) { - const url = btn.dataset.copyMdUrl; - const success = btn.dataset.copyMdSuccess ?? ''; - const errorMsg = btn.dataset.copyMdError ?? 'Copy failed'; - if (!url) return; - try { - const res = await fetch(url); - if (!res.ok) throw new Error('HTTP ' + res.status); - const text = await res.text(); - const ok = await this.writeToClipboard(text); - if (!ok) throw new Error('clipboard-write-failed'); - this.announce(btn, success); - } catch (err) { - console.error('[copy-md] failed:', err); - this.announce(btn, errorMsg); - } - } - - handleKeyboardNavigation(e) { - if (!this.menu.open) return; - - if (e.key === 'Escape') { - e.preventDefault(); - this.menu.open = false; - this.trigger.focus(); - } - } - - handleOutsideClick(e) { - if (!this.menu.open) return; - if (e.target instanceof Node && this.menu.contains(e.target)) return; - this.menu.open = false; - } - - addCopyHandlers() { - for (const btn of qsa('[data-copy-md-action="copy"]', this.menu)) { - btn.addEventListener('click', () => this.handleCopy(btn)); - } - } - - addListeners() { - this.menu.addEventListener('keydown', (e) => - this.handleKeyboardNavigation(e) - ); - - document.addEventListener('click', (e) => this.handleOutsideClick(e)); - } -} - -const copyMarkdownMenus = Array.from(qsa('[data-copy-md-menu]')).map( - (menu) => new CopyMarkdown(menu) -); - -export { copyMarkdownMenus }; diff --git a/src/styles/main.css b/src/styles/main.css index ab43dcb6d3..5035d66f63 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -1799,166 +1799,8 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path { mask: url('../assets/icons/github.svg') center / contain no-repeat; } -/* "Use Octopus docs with AI" dropdown */ -.octo-copy-md { - margin-block-start: var(--block-gap); -} - -.octo-copy-md__menu { - display: inline-block; - position: relative; -} - -.octo-copy-md__trigger { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.875rem; - border: 1px solid var(--border-color-menu-open); - border-radius: 999px; - background-color: transparent; - color: var(--color-menu-link); - font: inherit; - cursor: pointer; - list-style: none; - text-decoration: none; - user-select: none; - transition: - border-color 200ms cubic-bezier(0.4, 0, 0.2, 1), - background-color 200ms cubic-bezier(0.4, 0, 0.2, 1), - color 200ms cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 200ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.octo-copy-md__trigger > * { - text-decoration: none; - color: inherit; -} - -.octo-copy-md__trigger::-webkit-details-marker, -.octo-copy-md__trigger::marker { - content: ''; - display: none; -} - -.octo-copy-md__trigger-icon, -.octo-copy-md__trigger-caret { - display: inline-flex; - align-items: center; - justify-content: center; - line-height: 1; -} - -.octo-copy-md__trigger-icon::before { - content: '\f0eb'; /* fa-lightbulb */ - font-family: fa-solid; - font-size: 0.95em; - line-height: 1; - color: var(--color-menu-link-alt); - transition: color 200ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.octo-copy-md__trigger-caret::before { - content: '\f078'; /* fa-chevron-down */ - font-family: fa-solid; - font-size: 0.7em; - line-height: 1; - color: currentColor; - transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.octo-copy-md__menu[open] - > .octo-copy-md__trigger - .octo-copy-md__trigger-caret::before { - transform: rotate(180deg); -} - -.octo-copy-md__trigger:hover { - color: var(--color-menu-link-active); - border-color: var(--color-menu-link-alt); -} - -.octo-copy-md__menu[open] > .octo-copy-md__trigger { - color: var(--color-menu-link-active); - border-color: var(--color-menu-link-alt); - background-color: var(--bg-color-menu-open); - box-shadow: 0 0.0625rem 0.25rem rgba(13, 128, 216, 0.08); -} - -.octo-copy-md__trigger:focus-visible { - outline: 2px solid var(--color-menu-link-alt); - outline-offset: 2px; -} - -.octo-copy-md .octo-copy-md__options { - position: absolute; - z-index: 10; - inset-block-start: calc(100% + 0.5rem); - inset-inline-start: 0; - margin: 0; - padding: 0.5rem; - list-style: none; - background-color: var(--bg-color-menu); - border: 1px solid var(--border-color-menu-open); - border-radius: 0.625rem; - min-width: 20rem; - box-sizing: border-box; - overflow: hidden; - box-shadow: - 0 0.625rem 1.875rem rgba(15, 37, 53, 0.12), - 0 0.125rem 0.375rem rgba(15, 37, 53, 0.06); -} - -.octo-copy-md .octo-copy-md__options .octo-copy-md__option { - display: inline-flex; - align-items: center; - gap: 0.625rem; - padding: 0.625rem 0.75rem; - border: none; - border-radius: 0.375rem; - background: transparent; - color: var(--color-menu-link); - font: inherit; - text-align: start; - text-decoration: none; - cursor: pointer; - transition: - background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), - color 150ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.octo-copy-md .octo-copy-md__options .octo-copy-md__option:hover, -.octo-copy-md .octo-copy-md__options .octo-copy-md__option:focus-visible { - background-color: var(--bg-color-menu-open); - color: var(--color-menu-link-active); - outline: none; -} - -.octo-copy-md__option::before { - font-family: fa-solid; - font-size: 0.95em; - width: 1.1em; - text-align: center; - color: var(--color-menu-link-alt); - flex-shrink: 0; - transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.octo-copy-md__option--copy::before { - content: '\f0c5'; /* fa-copy */ -} - -.octo-copy-md__option--view::before { - content: '\f15c'; /* fa-file-lines */ -} - -.octo-copy-md__option--all::before { - content: '\f02d'; /* fa-book */ -} - /* Live regions for announcing the result of an action, such as copying a URL or a code block. Read by screen readers, never shown. */ -.octo-copy-md__sr-status, .copy-status { position: absolute; width: 1px; diff --git a/tests/llm-endpoints.spec.ts b/tests/llm-endpoints.spec.ts index 06a3c4c447..5e19d4f85a 100644 --- a/tests/llm-endpoints.spec.ts +++ b/tests/llm-endpoints.spec.ts @@ -120,31 +120,84 @@ test.describe('Copy as markdown page action', () => { }); }); -test('CopyMarkdown dropdown advertises a working .md URL on the eligible page', async ({ +test('Open in LLM hands every assistant a working .md URL', async ({ page, request, }) => { await page.goto(STABLE_PLAIN_MD_PATH); - const url = await page.getAttribute( - '[data-copy-md-action="copy"]', - 'data-copy-md-url' - ); + + const links = await page + .locator('.octo-llm a[href]') + .evaluateAll((all) => all.map((l) => (l as HTMLAnchorElement).href)); + expect( - url, - 'expected CopyMarkdown dropdown to expose a data-copy-md-url' - ).toBeTruthy(); - const target = await request.get(url!); + links.length, + 'expected the primary button plus two menu options' + ).toBe(3); + + // Each assistant carries the page URL inside its own prompt parameter, so the + // parameter name varies but the URL it wraps must not. + const mdUrls = new Set( + links.map((href) => { + const params = new URL(href).searchParams; + const prompt = params.get('q') ?? params.get('prompt') ?? ''; + return prompt.match(/https?:\/\/\S+\.md/)?.[0] ?? ''; + }) + ); + expect(mdUrls.size, 'every assistant should be pointed at one .md URL').toBe( + 1 + ); + + const mdUrl = [...mdUrls][0]; + expect(mdUrl).toContain(STABLE_PLAIN_MD_PATH + '.md'); + + // The prompt URL is absolute to production; test it against the preview host. + const target = await request.get(mdUrl.replace(/^https?:\/\/[^/]+/, '')); expect(target.status()).toBe(200); }); -test('CopyMarkdown dropdown is hidden on the ineligible MDX page', async ({ - page, -}) => { +test('Open in LLM is hidden on the ineligible MDX page', async ({ page }) => { await page.goto(STABLE_MDX_PATH); - const count = await page.locator('[data-copy-md-menu]').count(); - expect(count, 'expected no CopyMarkdown dropdown on ineligible page').toBe(0); + const count = await page.locator('[data-split-menu]').count(); + expect(count, 'expected no Open in LLM button on ineligible page').toBe(0); }); +// This is where the split button's menu is actually crowded: 700 puts the +// control at the end of a single row, 430 wraps it onto its own line at the +// start. The menu is wider than the control, so those two need it to open +// towards opposite sides. +for (const width of [700, 430]) { + test(`Open in LLM menu opens on screen at ${width}px wide`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto(STABLE_PLAIN_MD_PATH); + await page.locator('[data-split-trigger]').click(); + + const menu = page.locator('.split-btn__options'); + await expect(menu).toBeVisible(); + + const box = (await menu.boundingBox())!; + const viewport = await page.evaluate(() => ({ + client: document.documentElement.clientWidth, + scroll: document.documentElement.scrollWidth, + })); + + expect( + Math.round(box.x), + 'menu should not run off the start edge' + ).toBeGreaterThanOrEqual(0); + expect( + Math.round(box.x + box.width), + 'menu should not run off the end edge' + ).toBeLessThanOrEqual(viewport.client); + expect( + viewport.scroll, + 'an open menu should not force a horizontal scrollbar' + ).toBeLessThanOrEqual(viewport.client); + }); +} + test('HtmlHead omits `` on the ineligible MDX page', async ({ page, }) => {