From f685e288cf585390946f669b061e8f2223f2f5c6 Mon Sep 17 00:00:00 2001 From: Jordan Paulino Date: Wed, 13 May 2026 19:11:14 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8D=95=20feat(copy):=20inline=20'Copied'?= =?UTF-8?q?=20feedback=20+=20always-https=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related polish items on the panel's copy actions: 1) Every copy button now shows co-located confirmation - The toast at the bottom of the panel was easy to miss when the user's eyes were on the button they just clicked. Now the button itself swaps its icon to a checkmark and tints green for ~1.5s so confirmation is visible *exactly where the click happened*. - The toast still fires too — they cover different misses (toast for keyboard-shortcut copies and now-collapsed menus; inline for focused button copies). - Centralized in a new useCopyAction(durationMs?) hook that wraps copyToClipboard + pushToast + tracks the most-recently-used key so multi-button consumers (Copy as…, Export, Share menu) can give per-button feedback. 2) URL-flavored copies always carry a protocol - The Page URI / Component URI rows + the "Copy as → URI" button + the yp/yc keyboard shortcuts used to copy bare strings like `example.com/_components/x/instances/y`, which aren't paste-able into a browser, curl, or Slack chat. - New ensureProtocol() helper in clay-uri.ts prepends `https://` when no scheme is present, preserves an existing http(s):// to avoid silently upgrading localhost dev servers, and collapses leading // for protocol-relative inputs. - The displayed text in the row stays clean (no scheme); only the clipboard payload is normalized. The CopyableUri tooltip now surfaces the exact URL form so power users can verify before pasting. Surfaces touched ---------------- - CopyableUri (Page / Component URI rows): icon swap, green tint, copies via ensureProtocol. - ComponentDetails "Copy as…" row: per-button keyed feedback (URI, cURL, fetch, CSS) — clicking one only flips that button to "Copied" for 1.5s; URI button copies via ensureProtocol. - JsonPreview: header copy icon swaps to checkmark. - SeoTab JSON-LD cards: per-card copy icon swaps to checkmark; menu state preserved (block stays open). - ExportMenu: clicked item shows inline "Copied", menu auto-closes 900ms later so the affordance is visible before collapse. - ShareMenu: same pattern — main button + per-env menu items both show inline "Copied", cross-env menu auto-closes after 900ms. - Keyboard shortcuts (yp / yc): copy via ensureProtocol so they match what the in-panel buttons write. Tests ----- - 6 new ensureProtocol() cases covering bare URI, https://, http:// preservation, protocol-relative //, nullish/whitespace input, surrounding-whitespace trim. - 113 → 119 tests passing. Visual additions ---------------- - New `check` glyph in the Icon set. - `cs-link-copied`, `cs-icon-btn-copied`, `cs-uri-row-copy-copied`, `cs-export-item-copied` modifier classes — all use the success token (`--cs-success`) so they auto-adapt to light/dark themes. Co-authored-by: Cursor --- .../panel/components/ComponentDetails.tsx | 60 +++++++++---- src/content/panel/components/CopyableUri.tsx | 50 +++++++---- src/content/panel/components/ExportMenu.tsx | 55 ++++++++---- src/content/panel/components/Icon.tsx | 10 +++ src/content/panel/components/JsonPreview.tsx | 19 ++-- src/content/panel/components/SeoTab.tsx | 21 ++--- src/content/panel/components/ShareMenu.tsx | 86 +++++++++++-------- src/content/panel/hooks/useCopyAction.ts | 84 ++++++++++++++++++ .../panel/hooks/useKeyboardShortcuts.ts | 8 +- src/content/panel/styles.css | 40 +++++++++ src/lib/clay-uri.ts | 18 ++++ tests/lib/clay-uri.test.ts | 35 ++++++++ 12 files changed, 377 insertions(+), 109 deletions(-) create mode 100644 src/content/panel/hooks/useCopyAction.ts 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) => ( - - ))} + {OPTIONS.map((opt) => { + const copied = copiedKey === opt.format; + return ( + + ); + })} )} 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 ( ); } @@ -147,11 +152,11 @@ export function ShareMenu({ uri }: Props) {
- ))} + {menuTargets.map((t) => { + const copied = copiedKey === t.key; + return ( + + ); + })}
)} 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(