diff --git a/src/content/panel/components/ComponentDetails.tsx b/src/content/panel/components/ComponentDetails.tsx
index ee0d5b2..9c96b0a 100644
--- a/src/content/panel/components/ComponentDetails.tsx
+++ b/src/content/panel/components/ComponentDetails.tsx
@@ -4,12 +4,13 @@ import {
buildUrl,
copyAsCssSelector,
copyAsFetchSnippet,
+ ensureProtocol,
unpublishedUri,
} from '@/lib/clay-uri';
-import { copyToClipboard } from '@/lib/clipboard';
import { captureElementToClipboard } from '@/lib/screenshot';
import type { RuntimeMessage } from '@/lib/types';
import { getPanelHost } from '../../shadow-host';
+import { useCopyAction } from '../hooks/useCopyAction';
import { useEnvHost, useStore } from '../store';
import { CopyableUri } from './CopyableUri';
import { Icon } from './Icon';
@@ -21,6 +22,10 @@ export function ComponentDetails() {
const selected = useStore((s) => s.selected);
const pushToast = useStore((s) => s.pushToast);
const envHost = useEnvHost();
+ // Each "Copy as…" button needs its own inline-feedback signal so a click
+ // on cURL doesn't make every button flash "Copied". The hook tracks the
+ // most-recently-used key; siblings compare against it.
+ const { copy, copiedKey } = useCopyAction();
if (!selected) {
return (
@@ -34,11 +39,6 @@ export function ComponentDetails() {
chrome.runtime.sendMessage({ type: 'OPEN_TAB', url } satisfies RuntimeMessage);
};
- const copy = async (text: string, label: string) => {
- const ok = await copyToClipboard(text);
- pushToast(ok ? `${label} copied` : 'Copy failed', ok ? 'success' : 'error');
- };
-
const screenshot = async () => {
try {
const ok = await captureElementToClipboard(selected.element, getPanelHost());
@@ -103,26 +103,52 @@ export function ComponentDetails() {
Copy as…
-
diff --git a/src/content/panel/components/CopyableUri.tsx b/src/content/panel/components/CopyableUri.tsx
index e8c7ff2..b5615d1 100644
--- a/src/content/panel/components/CopyableUri.tsx
+++ b/src/content/panel/components/CopyableUri.tsx
@@ -1,14 +1,19 @@
-import { copyToClipboard } from '@/lib/clipboard';
-import { useStore } from '../store';
+import { ensureProtocol } from '@/lib/clay-uri';
+import { useCopyAction } from '../hooks/useCopyAction';
import { Icon } from './Icon';
interface CopyableUriProps {
- /** The string actually written to the clipboard. */
+ /**
+ * The Clay URI being represented. The actual string written to the
+ * clipboard is `ensureProtocol(uri)` so the user always pastes a
+ * fully-qualified URL — bare URIs aren't useful in a browser bar,
+ * curl, or chat tool.
+ */
readonly uri: string;
/**
- * Optional shorter text to display in the row. Defaults to `uri`. Useful
- * for component rows where the visible label is the instance ID but the
- * value worth copying is the full URI.
+ * Optional shorter text to display in the row. Defaults to `uri`.
+ * Useful for component rows where the visible label is the instance ID
+ * but the value worth copying is the full URI.
*/
readonly displayText?: string;
/** Used in the toast + accessible label, e.g. "Page URI" or "Component URI". */
@@ -19,21 +24,28 @@ interface CopyableUriProps {
* A URI rendered in muted monospace with a copy-icon button on the right
* of the same row. Hover (and keyboard focus) brightens the button so the
* affordance is discoverable but not visually noisy at rest.
+ *
+ * After a successful copy the icon swaps to a checkmark and the button
+ * picks up the `cs-uri-row-copy-copied` modifier class for ~1.5s, giving
+ * the user co-located confirmation without forcing them to hunt for the
+ * toast at the bottom of the panel.
*/
export function CopyableUri({ uri, displayText, label }: CopyableUriProps) {
- const pushToast = useStore((s) => s.pushToast);
+ const { copy, copiedKey } = useCopyAction();
+ const copied = copiedKey === 'default';
- const onCopy = async () => {
- const ok = await copyToClipboard(uri);
- pushToast(ok ? `${label} copied` : 'Copy failed', ok ? 'success' : 'error');
+ const onCopy = () => {
+ void copy(ensureProtocol(uri), label);
};
- // When the displayed text differs from the value being copied (component
- // rows show the instance id, copy yields the full URI), surface the full
- // URI in the tooltip so it's still discoverable.
- const tooltip =
- displayText && displayText !== uri
- ? `Copy ${label} to clipboard\n${uri}`
+ // The display text in the row stays as-is (short, clean, no scheme). The
+ // tooltip surfaces the *exact* string that will be copied so power users
+ // can verify the URL form before pasting.
+ const copiedText = ensureProtocol(uri);
+ const tooltip = copied
+ ? 'Copied!'
+ : displayText && displayText !== copiedText
+ ? `Copy ${label} to clipboard\n${copiedText}`
: `Copy ${label} to clipboard`;
return (
@@ -41,12 +53,12 @@ export function CopyableUri({ uri, displayText, label }: CopyableUriProps) {
{displayText ?? uri}
-
+
);
diff --git a/src/content/panel/components/ExportMenu.tsx b/src/content/panel/components/ExportMenu.tsx
index 00c02b1..c6bbd38 100644
--- a/src/content/panel/components/ExportMenu.tsx
+++ b/src/content/panel/components/ExportMenu.tsx
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { buildManifest, formatManifest } from '@/lib/exporter';
-import { copyToClipboard } from '@/lib/clipboard';
import type { ExportFormat } from '@/lib/types';
+import { useCopyAction } from '../hooks/useCopyAction';
import { useStore } from '../store';
+import { Icon } from './Icon';
const OPTIONS: Array<{ format: ExportFormat; label: string; help: string }> = [
{ format: 'json', label: 'JSON', help: 'Full structured data' },
@@ -26,6 +27,10 @@ export function ExportMenu() {
const page = useStore((s) => s.page);
const components = useStore((s) => s.components);
const pushToast = useStore((s) => s.pushToast);
+ // Each format gets its own copy key so a click on JSON doesn't briefly
+ // tag CSV as "Copied". The menu auto-closes a beat *after* the copy
+ // succeeds so the user sees the inline confirmation before it goes away.
+ const { copy, copiedKey } = useCopyAction();
useEffect(() => {
if (!open) return;
@@ -72,16 +77,19 @@ export function ExportMenu() {
};
const exportAs = async (format: ExportFormat) => {
- setOpen(false);
try {
const text = formatManifest(buildManifest(page, components), format);
- const ok = await copyToClipboard(text);
- pushToast(
- ok ? `${format.toUpperCase()} manifest copied to clipboard` : 'Copy failed',
- ok ? 'success' : 'error'
- );
+ const ok = await copy(text, `${format.toUpperCase()} manifest`, format);
+ if (ok) {
+ // Hold the menu open briefly so the user can see the inline "Copied"
+ // affordance light up before we collapse it.
+ setTimeout(() => setOpen(false), 900);
+ } else {
+ setOpen(false);
+ }
} catch (err) {
pushToast(err instanceof Error ? err.message : 'Export failed', 'error');
+ setOpen(false);
}
};
@@ -102,17 +110,28 @@ export function ExportMenu() {
role="menu"
style={{ top: coords.top, right: coords.right }}
>
- {OPTIONS.map((opt) => (
- void exportAs(opt.format)}
- className="cs-export-item"
- >
- Copy as {opt.label}
- {opt.help}
-
- ))}
+ {OPTIONS.map((opt) => {
+ const copied = copiedKey === opt.format;
+ return (
+ void exportAs(opt.format)}
+ className={`cs-export-item ${copied ? 'cs-export-item-copied' : ''}`}
+ >
+
+ {copied ? (
+ <>
+ Copied
+ >
+ ) : (
+ <>Copy as {opt.label}>
+ )}
+
+ {opt.help}
+
+ );
+ })}
)}
diff --git a/src/content/panel/components/Icon.tsx b/src/content/panel/components/Icon.tsx
index 8352009..280c452 100644
--- a/src/content/panel/components/Icon.tsx
+++ b/src/content/panel/components/Icon.tsx
@@ -159,6 +159,16 @@ const ICONS = {
>
),
+ check: (
+
+ ),
} as const;
export type IconName = keyof typeof ICONS;
diff --git a/src/content/panel/components/JsonPreview.tsx b/src/content/panel/components/JsonPreview.tsx
index d31d394..be91ab0 100644
--- a/src/content/panel/components/JsonPreview.tsx
+++ b/src/content/panel/components/JsonPreview.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { buildUrl } from '@/lib/clay-uri';
-import { copyToClipboard } from '@/lib/clipboard';
import { highlightJson } from '@/lib/json-highlight';
+import { useCopyAction } from '../hooks/useCopyAction';
import { useEnvHost, useStore } from '../store';
import { Icon } from './Icon';
@@ -25,8 +25,9 @@ function initialStateFor(uri: string | null, host: string): FetchState {
export function JsonPreview() {
const selected = useStore((s) => s.selected);
const page = useStore((s) => s.page);
- const pushToast = useStore((s) => s.pushToast);
const envHost = useEnvHost();
+ const { copy, copiedKey } = useCopyAction();
+ const copied = copiedKey === 'default';
const targetUri = selected?.uri ?? page?.pageUri ?? null;
const fetchUrl = targetUri ? buildUrl(targetUri, '.json', envHost) : null;
@@ -95,17 +96,21 @@ export function JsonPreview() {
return null;
}
- const onCopy = async () => {
- const ok = await copyToClipboard(JSON.stringify(state.data, null, 2));
- pushToast(ok ? 'JSON copied' : 'Copy failed', ok ? 'success' : 'error');
+ const onCopy = () => {
+ void copy(JSON.stringify(state.data, null, 2), 'JSON');
};
return (
JSON
-
-
+
+
diff --git a/src/content/panel/components/SeoTab.tsx b/src/content/panel/components/SeoTab.tsx
index a234be2..531dab0 100644
--- a/src/content/panel/components/SeoTab.tsx
+++ b/src/content/panel/components/SeoTab.tsx
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
-import { copyToClipboard } from '@/lib/clipboard';
import { highlightJson } from '@/lib/json-highlight';
import {
extractSeoMeta,
@@ -10,7 +9,7 @@ import {
type SeoIssue,
type SeoMeta,
} from '@/lib/seo';
-import { useStore } from '../store';
+import { useCopyAction } from '../hooks/useCopyAction';
import { Icon } from './Icon';
const TONE: Record = {
@@ -76,22 +75,22 @@ function JsonLdBlockCard({
index: number;
issues: readonly JsonLdIssue[];
}) {
- const pushToast = useStore((s) => s.pushToast);
// Open by default if the block has any errors so the user immediately
// sees what's wrong without an extra click.
const hasError = issues.some((i) => i.severity === 'error');
const [open, setOpen] = useState(hasError);
const summary = summarizeJsonLd(block);
const headerSeverity = worstSeverity(issues);
+ const { copy, copiedKey } = useCopyAction();
+ const copied = copiedKey === 'default';
- const onCopy = async (e: React.MouseEvent) => {
+ const onCopy = (e: React.MouseEvent) => {
// Prevent the click from toggling the open state.
e.preventDefault();
e.stopPropagation();
const text =
summary.invalid && isInvalidBlock(block) ? (block.raw ?? '') : JSON.stringify(block, null, 2);
- const ok = await copyToClipboard(text);
- pushToast(ok ? `Copied JSON-LD block #${index + 1}` : 'Copy failed', ok ? 'success' : 'error');
+ void copy(text, `JSON-LD block #${index + 1}`);
};
const cardClasses = ['cs-jsonld-card'];
@@ -135,12 +134,14 @@ function JsonLdBlockCard({
)}
-
+
{/* Body is only mounted once expanded — saves the syntax-highlight
diff --git a/src/content/panel/components/ShareMenu.tsx b/src/content/panel/components/ShareMenu.tsx
index d18d1b8..ced3938 100644
--- a/src/content/panel/components/ShareMenu.tsx
+++ b/src/content/panel/components/ShareMenu.tsx
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { buildShareLink } from '@/lib/clay-uri';
-import { copyToClipboard } from '@/lib/clipboard';
import { availableEnvsFor, findMappingForHost, rewriteUrlToEnv } from '@/lib/site-host';
import { SITE_ENV_LABELS, SITE_ENV_ORDER, type SiteEnv } from '@/lib/types';
+import { useCopyAction } from '../hooks/useCopyAction';
import { useStore } from '../store';
import { Icon } from './Icon';
@@ -39,6 +39,10 @@ export function ShareMenu({ uri }: Props) {
const siteHosts = useStore((s) => s.preferences.siteHosts);
const pushToast = useStore((s) => s.pushToast);
+ // Two visible buttons can each be the trigger ("Share" main + per-env
+ // menu items), so we use the keyed form of the hook.
+ const { copy, copiedKey } = useCopyAction();
+ const mainCopied = copiedKey === 'main';
// Cross-env targets are derived from configuration only — the URL itself
// is computed at click time so SPA navigation can never produce a stale
@@ -85,32 +89,33 @@ export function ShareMenu({ uri }: Props) {
};
}, [open]);
- const copyShare = useCallback(
- async (targetUrl: string, label: string) => {
- const ok = await copyToClipboard(targetUrl);
- pushToast(ok ? `Share link copied (${label})` : 'Copy failed', ok ? 'success' : 'error');
- },
- [pushToast]
- );
-
const onShareClick = useCallback(() => {
- setOpen(false);
const currentMatch = findMappingForHost(location.hostname, siteHosts);
- const label = currentMatch ? `Current — ${SITE_ENV_LABELS[currentMatch.env]}` : 'Current page';
- void copyShare(buildShareLink(location.href, uri), label);
- }, [copyShare, siteHosts, uri]);
+ const label = currentMatch ? `Share link (${SITE_ENV_LABELS[currentMatch.env]})` : 'Share link';
+ void copy(buildShareLink(location.href, uri), label, 'main');
+ }, [copy, siteHosts, uri]);
const onMenuClick = useCallback(
- (target: MenuTarget) => {
- setOpen(false);
+ async (target: MenuTarget) => {
const rewritten = rewriteUrlToEnv(location.href, target.env, siteHosts);
if (!rewritten) {
pushToast(`No ${target.label} host configured for this site`, 'error');
+ setOpen(false);
return;
}
- void copyShare(buildShareLink(rewritten, uri), target.label);
+ const ok = await copy(
+ buildShareLink(rewritten, uri),
+ `Share link (${target.label})`,
+ target.key
+ );
+ if (ok) {
+ // Hold the menu open briefly so the inline "Copied" affordance is visible.
+ setTimeout(() => setOpen(false), 900);
+ } else {
+ setOpen(false);
+ }
},
- [copyShare, pushToast, siteHosts, uri]
+ [copy, pushToast, siteHosts, uri]
);
const toggle = useCallback(() => {
@@ -134,11 +139,11 @@ export function ShareMenu({ uri }: Props) {
return (
- Share
+ {mainCopied ? 'Copied' : 'Share'}
);
}
@@ -147,11 +152,11 @@ export function ShareMenu({ uri }: Props) {
)}
diff --git a/src/content/panel/hooks/useCopyAction.ts b/src/content/panel/hooks/useCopyAction.ts
new file mode 100644
index 0000000..9055060
--- /dev/null
+++ b/src/content/panel/hooks/useCopyAction.ts
@@ -0,0 +1,84 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { copyToClipboard } from '@/lib/clipboard';
+import { useStore } from '../store';
+
+/**
+ * Default key used by single-button consumers that don't need to
+ * disambiguate between siblings. Picked as a string instead of `null`
+ * so callers can always do `copiedKey === 'btnA'` without null-checks.
+ */
+const DEFAULT_KEY = 'default';
+const DEFAULT_DURATION_MS = 1500;
+
+interface UseCopyActionResult {
+ /**
+ * Copy `text` to the clipboard, push a feedback toast, and (on success)
+ * mark `key` as the most recently copied so the calling component can
+ * render an inline "Copied" affordance for `durationMs`.
+ *
+ * - `text` : what to write to the clipboard verbatim. Caller is
+ * responsible for any normalization (e.g. `ensureProtocol`).
+ * - `toastLabel` : short noun used in the toast — e.g. passing `"URI"`
+ * yields the toast text `"URI copied"`.
+ * - `key` : optional disambiguator when one component renders
+ * multiple copy buttons. Defaults to `'default'`.
+ */
+ readonly copy: (text: string, toastLabel: string, key?: string) => Promise;
+ /**
+ * Key of the most recently copied button, or `null` once the inline
+ * feedback window has elapsed. `=== DEFAULT_KEY` for single-button uses.
+ */
+ readonly copiedKey: string | null;
+}
+
+/**
+ * Bundles the three things every copy button in the panel needs:
+ *
+ * 1. The actual `navigator.clipboard.writeText` call (delegated to
+ * `copyToClipboard`).
+ * 2. A toast push so the user gets *some* feedback even if their eyes
+ * are away from the button (e.g. they triggered copy via shortcut
+ * or the button is in a now-collapsed menu).
+ * 3. A short-lived `copiedKey` flag so the button itself can render an
+ * inline "Copied" affordance (icon swap, label change, tinted border
+ * — caller's choice).
+ *
+ * Why both a toast *and* an inline indicator? They cover different
+ * failure modes of feedback: a toast is global but easy to miss if the
+ * user is focused on the button; the inline indicator is co-located but
+ * disappears if the menu collapses. Together they guarantee one of them
+ * is always visible right after a click.
+ */
+export function useCopyAction(durationMs = DEFAULT_DURATION_MS): UseCopyActionResult {
+ const [copiedKey, setCopiedKey] = useState(null);
+ const timerRef = useRef | null>(null);
+ const pushToast = useStore((s) => s.pushToast);
+
+ // If the consumer unmounts during the post-copy window (e.g. user
+ // collapses the panel right after copying), make sure the timeout
+ // doesn't stamp on a defunct setState.
+ useEffect(
+ () => () => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ },
+ []
+ );
+
+ const copy = useCallback(
+ async (text: string, toastLabel: string, key: string = DEFAULT_KEY) => {
+ const ok = await copyToClipboard(text);
+ if (ok) {
+ setCopiedKey(key);
+ if (timerRef.current) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => setCopiedKey(null), durationMs);
+ pushToast(`${toastLabel} copied`, 'success');
+ } else {
+ pushToast('Copy failed', 'error');
+ }
+ return ok;
+ },
+ [durationMs, pushToast]
+ );
+
+ return { copy, copiedKey };
+}
diff --git a/src/content/panel/hooks/useKeyboardShortcuts.ts b/src/content/panel/hooks/useKeyboardShortcuts.ts
index 8e16906..e0174d0 100644
--- a/src/content/panel/hooks/useKeyboardShortcuts.ts
+++ b/src/content/panel/hooks/useKeyboardShortcuts.ts
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { copyToClipboard } from '@/lib/clipboard';
-import { buildUrl } from '@/lib/clay-uri';
+import { buildUrl, ensureProtocol } from '@/lib/clay-uri';
import type { RuntimeMessage } from '@/lib/types';
import { useStore } from '../store';
@@ -80,10 +80,12 @@ export function useKeyboardShortcuts(): void {
clearPending();
if (combo === 'yp' && page) {
- const ok = await copyToClipboard(page.pageUri);
+ // Always copy a fully-qualified URL so the keyboard shortcut
+ // matches what the in-panel copy button writes.
+ const ok = await copyToClipboard(ensureProtocol(page.pageUri));
pushToast(ok ? 'Page URI copied' : 'Copy failed', ok ? 'success' : 'error');
} else if (combo === 'yc' && selected) {
- const ok = await copyToClipboard(selected.uri);
+ const ok = await copyToClipboard(ensureProtocol(selected.uri));
pushToast(ok ? 'Component URI copied' : 'Copy failed', ok ? 'success' : 'error');
} else if (combo === 'op' && page) {
chrome.runtime.sendMessage({
diff --git a/src/content/panel/styles.css b/src/content/panel/styles.css
index 2201cf4..8b2a639 100644
--- a/src/content/panel/styles.css
+++ b/src/content/panel/styles.css
@@ -369,6 +369,33 @@
filter: brightness(1.05);
}
+/* ── "Copied" inline confirmation state ────────────────────────────────
+ Applied to copy-action buttons for ~1.5s after a successful copy.
+ Picks up the success color so the affordance is unambiguous; pointer
+ stays as default-cursor since clicking again just re-copies. */
+.cs-link.cs-link-copied {
+ background: var(--cs-success);
+ border-color: var(--cs-success);
+ color: #fff !important;
+ cursor: default;
+}
+
+.cs-link.cs-link-copied:hover {
+ background: var(--cs-success);
+ border-color: var(--cs-success);
+ color: #fff !important;
+ filter: brightness(1.05);
+}
+
+/* Smaller variant for the icon-only buttons (CopyableUri row + JSON tab
+ header + JSON-LD card header). The icon is already swapped to a check
+ mark in JSX; the green tint provides the secondary signal. */
+.cs-icon-btn.cs-icon-btn-copied,
+.cs-uri-row-copy.cs-uri-row-copy-copied {
+ color: var(--cs-success);
+ opacity: 1;
+}
+
.cs-empty {
text-align: center;
padding: 32px 16px;
@@ -1028,6 +1055,19 @@
background: var(--cs-bg-hover);
}
+.cs-export-item.cs-export-item-copied,
+.cs-export-item.cs-export-item-copied:hover {
+ background: rgba(22, 163, 74, 0.12);
+ cursor: default;
+}
+
+.cs-export-item.cs-export-item-copied .cs-export-label {
+ color: var(--cs-success);
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
.cs-export-label {
font-weight: 600;
font-size: 12px;
diff --git a/src/lib/clay-uri.ts b/src/lib/clay-uri.ts
index e37167a..8f25f43 100644
--- a/src/lib/clay-uri.ts
+++ b/src/lib/clay-uri.ts
@@ -78,6 +78,24 @@ export function splitHostAndPath(uri: string): { host: string; path: string } {
return { host: '', path: '/' + cleaned };
}
+/**
+ * Prepend `https://` to a Clay URI when no protocol is present, so the
+ * resulting string is paste-able into a browser, curl, or any other URL
+ * consumer. URIs already carrying `http://` or `https://` are returned
+ * unchanged so we never silently rewrite an explicitly-http reference.
+ *
+ * Empty / nullish input returns an empty string. Any leading `//` is
+ * collapsed to keep the resulting URL well-formed in case a caller passes
+ * a protocol-relative URI.
+ */
+export function ensureProtocol(uri: string | null | undefined): string {
+ if (!uri) return '';
+ const trimmed = uri.trim();
+ if (!trimmed) return '';
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
+ return `https://${trimmed.replace(/^\/+/, '')}`;
+}
+
/**
* Normalizes a user-provided host string into a `protocol://hostname` form
* with no trailing slash. Returns an empty string when no host is set.
diff --git a/tests/lib/clay-uri.test.ts b/tests/lib/clay-uri.test.ts
index 675c4b8..50c19e5 100644
--- a/tests/lib/clay-uri.test.ts
+++ b/tests/lib/clay-uri.test.ts
@@ -7,6 +7,7 @@ import {
buildUrl,
copyAsCssSelector,
copyAsFetchSnippet,
+ ensureProtocol,
getComponentName,
getDisplayName,
getInstance,
@@ -288,6 +289,40 @@ describe('buildShareLink + parseShareTarget', () => {
});
});
+describe('ensureProtocol', () => {
+ it('prepends https:// to a bare Clay URI', () => {
+ expect(ensureProtocol('example.com/_components/x/instances/y')).toBe(
+ 'https://example.com/_components/x/instances/y'
+ );
+ });
+
+ it('preserves an existing https:// scheme', () => {
+ expect(ensureProtocol('https://example.com/_pages/foo')).toBe('https://example.com/_pages/foo');
+ });
+
+ it('preserves an existing http:// scheme (does not silently upgrade)', () => {
+ // Caller may be working against a local http://localhost:3001 dev server;
+ // we must not rewrite to https:// or curl/fetch will fail TLS handshakes.
+ expect(ensureProtocol('http://localhost:3001/_pages/x')).toBe('http://localhost:3001/_pages/x');
+ });
+
+ it('handles protocol-relative URIs by collapsing the leading //', () => {
+ expect(ensureProtocol('//example.com/_pages/foo')).toBe('https://example.com/_pages/foo');
+ });
+
+ it('returns empty string for nullish or whitespace-only input', () => {
+ expect(ensureProtocol(undefined)).toBe('');
+ expect(ensureProtocol(null)).toBe('');
+ expect(ensureProtocol('')).toBe('');
+ expect(ensureProtocol(' ')).toBe('');
+ });
+
+ it('trims surrounding whitespace before deciding whether to prepend', () => {
+ expect(ensureProtocol(' example.com/_pages/x ')).toBe('https://example.com/_pages/x');
+ expect(ensureProtocol(' https://example.com ')).toBe('https://example.com');
+ });
+});
+
describe('copy helpers', () => {
it('builds a fetch snippet against the env host', () => {
const snip = copyAsFetchSnippet(