Skip to content
Open
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
284 changes: 284 additions & 0 deletions src/lib/components/PromptBanner.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
<script lang="ts">
import { createCopy } from '$lib/utils/copy';
import { getPrompt, hasPrompt } from '$lib/utils/prompts';
import { fly } from 'svelte/transition';
import { Button, Icon } from '$lib/components/ui';
import { Tooltip } from '$lib/components';
import { createDropdownMenu, melt } from '@melt-ui/svelte';
import { onMount } from 'svelte';

export let promptName: string;

const prompt = getPrompt(promptName) ?? '';
const exists = hasPrompt(promptName);
const { copied, copy } = createCopy(prompt);
onMount(() => {
copied.set(false);
});

type Ide = 'copy' | 'cursor' | 'chatgpt' | 'claude';

// Local dropdown configured to open to the left (bottom-end)
const {
elements: { trigger, menu },
states: { open }
} = createDropdownMenu({
forceVisible: true,
positioning: {
placement: 'bottom-end'
}
});

function openIde(value: Ide) {
if (value === 'copy') {
copy();
return;
}

open.set(false);

const text = encodeURIComponent(prompt);

if (value === 'cursor') {
const url = `https://cursor.com/link/prompt?text=${text}`;
window.open(url, '_blank', 'noopener,noreferrer');
return;
}

if (value === 'chatgpt') {
const url = `https://chatgpt.com/?prompt=${text}`;
window.open(url, '_blank', 'noopener,noreferrer');
return;
}

if (value === 'claude') {
const url = `https://claude.ai/new?q=${text}`;
window.open(url, '_blank', 'noopener,noreferrer');
return;
}
}
function handleMainClick() {
copy();
}
</script>

{#if exists}
<div class="ai-banner">
<div class="ai-banner_content">
<div class="ai-banner_title">
<Icon name="sparkle" class="text-primary" aria-hidden="true" />
<span>Use AI agent</span>
</div>
<div class="ai-banner_actions">
<div class="flex">
<Tooltip disabled={!$copied}>
<Button
variant="secondary"
onclick={handleMainClick}
aria-label="Copy prompt"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Dynamic aria-label for accessibility.

The aria-label should reflect the button's current state. Update it to match the copied state:

-                        aria-label="Copy prompt"
+                        aria-label={$copied ? 'Copied' : 'Copy prompt'}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
aria-label="Copy prompt"
aria-label={$copied ? 'Copied' : 'Copy prompt'}
🤖 Prompt for AI Agents
In src/lib/components/PromptBanner.svelte around line 78, the static
aria-label="Copy prompt" should be made dynamic so it reflects the button's
current state; bind aria-label to your copied state (e.g. aria-label={copied ?
"Copied" : "Copy prompt"}) and ensure the copied boolean is updated when the
copy action succeeds and reset after the timeout so screen readers receive the
current status.

class="no-right-radius"
>
<Icon name="copy" aria-hidden="true" />
<span>Copy prompt</span>
</Button>
{#snippet tooltip()}
Copied
{/snippet}
</Tooltip>

<button
class="no-left-radius web-button is-secondary"
use:melt={$trigger}
aria-label="Open options"
>
{#if $open}
<span class="web-icon-chevron-up" aria-hidden="true"></span>
{:else}
<span class="web-icon-chevron-down" aria-hidden="true"></span>
{/if}
</button>

{#if $open}
<div
class="menu-wrapper web-select-menu is-normal menu z-1"
use:melt={$menu}
transition:fly={{ y: 8, duration: 250 }}
>
<ul class="text-sub-body">
<li>
<button
type="button"
class="menu-btn"
onclick={() => {
openIde('cursor');
}}
>
<img
src="/images/docs/mcp/logos/dark/cursor-ai.svg"
alt=""
class="web-u-only-dark"
width="16"
height="16"
/>
<img
src="/images/docs/mcp/logos/cursor-ai.svg"
alt=""
class="web-u-only-light"
width="16"
height="16"
/>
<span>Open in Cursor</span>
</button>
</li>
<li>
<button
type="button"
class="menu-btn"
onclick={() => {
openIde('chatgpt');
}}
>
<img
src="/images/docs/mcp/logos/dark/openai.svg"
alt=""
class="web-u-only-dark"
width="16"
height="16"
/>
<img
src="/images/docs/mcp/logos/openai.svg"
alt=""
class="web-u-only-light"
width="16"
height="16"
/>
<span>Ask ChatGPT</span>
</button>
</li>
<li>
<button
type="button"
class="menu-btn"
onclick={() => {
openIde('claude');
}}
>
<img
src="/images/docs/mcp/logos/dark/claude.svg"
alt=""
class="web-u-only-dark"
width="16"
height="16"
/>
<img
src="/images/docs/mcp/logos/claude.svg"
alt=""
class="web-u-only-light"
width="16"
height="16"
/>
<span>Ask Claude</span>
</button>
</li>
</ul>
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}

<style lang="scss">
.ai-banner {
padding: 12px 16px;
border: 1px solid hsl(var(--web-color-border));
border-radius: 12px;
background: hsl(var(--web-color-surface));
margin-block: 12px 16px;

&_content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}

&_title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}

&_actions {
display: flex;
align-items: center;
gap: 8px;
}
}
/* dropdown styles */
.menu-wrapper {
--p-card-border-radius: 0.5rem;
padding: 4px;
backdrop-filter: blur(2px);
--webkit-backdrop-filter: blur(2px);
z-index: 100;
}

ul {
min-width: 14.375rem;
display: flex;
flex-direction: column;
gap: 2px;
}

.menu-btn {
height: 32px;
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: 0.5rem;
padding: 5px 10px;
width: 100%;
text-align: left;
}

.menu-btn:hover {
cursor: pointer;
background-color: hsl(var(--web-color-offset));
}

/* Force caret icon to be white inside secondary button */
.ai-banner [class*='icon'] {
color: hsl(var(--web-color-white));
}

:global(.ai-banner .web-button.no-left-radius [class*='icon']) {
color: hsl(var(--web-color-white));
}

/* Style child component output: adjust both element and its gradient pseudo-elements */
.ai-banner :global(.web-button.no-left-radius),
.ai-banner :global(.web-button.no-left-radius)::before,
.ai-banner :global(.web-button.no-left-radius)::after {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 0;
}

.ai-banner :global(.web-button.no-right-radius),
.ai-banner :global(.web-button.no-right-radius)::before,
.ai-banner :global(.web-button.no-right-radius)::after {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}

.ai-banner :global(.web-button),
.ai-banner :global(.web-button)::before,
.ai-banner :global(.web-button)::after {
padding-left: 10px;
padding-right: 10px;
}
</style>
41 changes: 41 additions & 0 deletions src/lib/utils/prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Utility to load prompt text files from `src/prompts`, including nested folders
// Consumers must pass the file path relative to `src/prompts/`, including extension

const promptsGlob = import.meta.glob('/src/prompts/**/*', {
query: '?raw',
import: 'default',
eager: true
}) as Record<string, string>;

function toRelativeKey(fullPath: string): string {
// Convert absolute like "/src/prompts/foo/bar.md" to "foo/bar.md"
return fullPath.replace(/^\/?src\/prompts\//, '');
}

function normalizeInputKey(input: string): string {
// Normalize user input like "./foo\\bar.md" → "foo/bar.md"
return input.replace(/^\/*/, '').replace(/\\/g, '/');
}

const nameToPrompt: Record<string, string> = Object.entries(promptsGlob).reduce(
(acc, [path, contents]) => {
const key = toRelativeKey(path);
acc[key] = contents;
return acc;
},
{} as Record<string, string>
);

export function getPrompt(filePathWithExt: string): string | null {
const key = normalizeInputKey(filePathWithExt);
return nameToPrompt[key] ?? null;
}

export function hasPrompt(filePathWithExt: string): boolean {
const key = normalizeInputKey(filePathWithExt);
return key in nameToPrompt;
}

export function listPrompts(): string[] {
return Object.keys(nameToPrompt).sort();
}
5 changes: 5 additions & 0 deletions src/markdoc/layouts/Article.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import { MainFooter } from '$lib/components';
import SeoOgImage from '$lib/components/SeoOgImage.svelte';
import { DocsArticle } from '$lib/layouts';
import PromptBanner from '$lib/components/PromptBanner.svelte';
import type { TocItem } from '$lib/layouts/DocsArticle.svelte';
import { DOCS_TITLE_SUFFIX, OVERVIEW_TITLE_SUFFIX } from '$routes/titles';
import { getContext, setContext } from 'svelte';
Expand All @@ -29,6 +30,7 @@
export let difficulty: string | undefined = undefined;
export let readtime: string | undefined = undefined;
export let date: string | undefined = undefined;
export let prompt: string | undefined = undefined;
setContext<LayoutContext>('headings', writable({}));
Expand Down Expand Up @@ -87,6 +89,9 @@
<li>{readtime} min</li>
{/if}
</svelte:fragment>
{#if prompt}
<PromptBanner promptName={prompt} />
{/if}
<slot />
</DocsArticle>
<MainFooter variant="docs" />
Loading