Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/components/OpenInLlm.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
import { SITE } from '@config';
import { Lang, Translations } from '@util/Languages';
import { getPageMarkdownPath } from '@util/mdxContent';
import SplitButton from './SplitButton.astro';

type Props = {
lang: string;
};
const { lang } = Astro.props satisfies Props;

const _ = Lang(lang);

const pageMdUrl = getPageMarkdownPath(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 && (
<SplitButton
class="octo-llm"
label={primary.label}
href={primary.href}
icon={primary.icon}
size="small"
items={assistants}
menuLabel={_(Translations.octopus_open_in_llm.more_assistants)}
target="_blank"
rel="noopener"
/>
)
}

<style>
/* `:global()` crosses into SplitButton's scope, which renders every element
these land on; the `.octo-llm` prefix keeps them contained. */
.octo-llm :global(.octo-llm__icon) {
background-color: currentColor;
}

/* The exported glyphs sit inside a box 25% wider than themselves, so the mask
is sized rather than left to fill. */
.octo-llm :global(.btn .octo-llm__icon) {
--octo-llm-glyph-size: 1rem;
}

.octo-llm :global(.split-btn__option-icon) {
--octo-llm-glyph-size: 0.8rem;
}

.octo-llm :global(.octo-llm__icon--claude) {
mask: url('../assets/icons/claude.svg') center / var(--octo-llm-glyph-size)
no-repeat;
}

.octo-llm :global(.octo-llm__icon--chatgpt) {
mask: url('../assets/icons/chatgpt.svg') center / var(--octo-llm-glyph-size)
no-repeat;
}

.octo-llm :global(.octo-llm__icon--external) {
mask: url('../assets/icons/external-link.svg') center /
var(--octo-llm-glyph-size) no-repeat;
}
</style>
14 changes: 14 additions & 0 deletions src/data/language.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,20 @@
"en": "Copy failed"
}
},
"octopus_open_in_llm": {
"open_in_claude": {
"en": "Open in Claude"
},
"open_in_chatgpt": {
"en": "Open in ChatGPT"
},
"more_assistants": {
"en": "Open in another AI assistant"
},
"prompt": {
"en": "Read {url} so I can ask questions about it."
}
},
"post": {
"written_by": {
"en": ""
Expand Down
2 changes: 2 additions & 0 deletions src/layouts/Default.astro
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import ArticleJourney from '../components/ArticleJourney.astro';
import EditOnGithub from '../components/EditOnGithub.astro';
import CopyAsMarkdown from '../components/CopyAsMarkdown.astro';
import MarkdownLinks from '../components/MarkdownLinks.astro';
import OpenInLlm from '../components/OpenInLlm.astro';
import Plausible from 'src/components/Plausible.astro';
import Footer from 'src/components/Footer.astro';

Expand Down Expand Up @@ -85,6 +86,7 @@ const lastUpdated = frontmatter.modDate ?? frontmatter.pubDate ?? null;
<ArticleHeader lang={lang} subtitle={subtitle} title={title} />
<div class="page-actions">
<CopyAsMarkdown lang={lang} />
<OpenInLlm lang={lang} />
<EditOnGithub
frontmatter={frontmatter}
headings={headings}
Expand Down
94 changes: 94 additions & 0 deletions tests/open-in-llm.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { test, expect } from '@playwright/test';
import type { Locator } from '@playwright/test';

// Runs against `astro preview` (built `dist/`), because the `.md` companion the
// button points at is written by the build-time integration.

const STABLE_PLAIN_MD_PATH = '/docs/argo-cd';
const STABLE_MDX_PATH = '/docs/kubernetes';

// The menu scales as it opens, so a box measured before that finishes is the
// box part way through the animation rather than the one being asserted on.
async function opened(options: Locator) {
await expect(options).toBeVisible();
await options.evaluate((el) =>
Promise.all(el.getAnimations().map((animation) => animation.finished))
);
}

test('every assistant is handed a working .md URL', async ({
page,
request,
}) => {
await page.goto(STABLE_PLAIN_MD_PATH);

const links = await page
.locator('.octo-llm a[href]')
.evaluateAll((all) => all.map((l) => (l as HTMLAnchorElement).href));

expect(
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('the button is absent on a page with no .md companion', async ({
page,
}) => {
await page.goto(STABLE_MDX_PATH);
const count = await page.locator('.octo-llm').count();
expect(count, 'expected no Open in Claude button on ineligible page').toBe(0);
});

// Where the 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(`the 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('.octo-llm [data-split-trigger]').click();

const menu = page.locator('.octo-llm .split-btn__options');
await opened(menu);

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);
});
}