@ai-matrx/kit 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -91,6 +91,19 @@ async function writeClipboard(text) {
91
91
  try {
92
92
  await navigator.clipboard.writeText(text);
93
93
  return true;
94
+ } catch {
95
+ }
96
+ try {
97
+ const scratch = document.createElement("textarea");
98
+ scratch.value = text;
99
+ scratch.setAttribute("readonly", "");
100
+ scratch.style.position = "fixed";
101
+ scratch.style.opacity = "0";
102
+ document.body.appendChild(scratch);
103
+ scratch.select();
104
+ const copied = document.execCommand("copy");
105
+ scratch.remove();
106
+ return copied;
94
107
  } catch {
95
108
  return false;
96
109
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/short-link-react.tsx","../src/short-link.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/short-link-react — the drop-in short-link UI, headless included.\n *\n * The five-minute story: import the button, hand it your Supabase client, done.\n * The mint call (the org-gated `shorten_app_url` door), the URL shape, the\n * clipboard write, and the state feedback all live HERE — a consuming app\n * writes zero shortener logic.\n *\n * <CopyShortLinkButton client={supabase} path={`/notes/${id}`} organizationId={orgId} />\n * <CopyPageShortLinkButton client={supabase} organizationId={orgId} /> // THIS page, query string and all\n *\n * Custom chrome (a menu row, a context-menu item, an icon button)? Use the\n * headless hook and keep only the markup:\n *\n * const { copy, phase } = useCopyShortLink(supabase, { organizationId });\n * <button onClick={copy}>{phase === \"copied\" ? \"Copied\" : \"Copy short link\"}</button>\n *\n * Omitting `path` means \"the current page at the moment of the click\" —\n * pathname + query + hash, so a sorted/filtered/column-configured view\n * shortens to exactly that view.\n *\n * Runtime deps of this subpath: none beyond React.\n * Styling is the Matrx Tailwind semantic-token vocabulary; override via\n * `className` (the class string is appended).\n */\n\nimport { useCallback, useRef, useState } from \"react\";\n\nimport {\n currentAppPath,\n mintShortLink,\n type MintShortLinkOptions,\n type MintShortLinkResult,\n type ShortLinkClient,\n} from \"./short-link\";\n\nasync function writeClipboard(text: string): Promise<boolean> {\n try {\n await navigator.clipboard.writeText(text);\n return true;\n } catch {\n return false;\n }\n}\n\nexport interface UseShortLinkResult {\n /** Mint (once) and return the short URL; re-calls return the cached mint. */\n mint: () => Promise<MintShortLinkResult>;\n /** The minted URL, once `mint` has succeeded. */\n url: string | null;\n minting: boolean;\n error: string | null;\n}\n\n/** Mint-on-demand with caching — a fixed path known at render time. */\nexport function useShortLink(\n client: ShortLinkClient,\n options: MintShortLinkOptions,\n): UseShortLinkResult {\n const [url, setUrl] = useState<string | null>(null);\n const [minting, setMinting] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const cached = useRef<MintShortLinkResult | null>(null);\n\n const mint = useCallback(async (): Promise<MintShortLinkResult> => {\n if (cached.current?.ok) return cached.current;\n setMinting(true);\n setError(null);\n try {\n const result = await mintShortLink(client, options);\n cached.current = result;\n if (result.ok) {\n setUrl(result.url);\n } else {\n setError(result.error);\n }\n return result;\n } finally {\n setMinting(false);\n }\n }, [client, options.path, options.organizationId, options.expiresAt, options.origin]);\n\n return { mint, url, minting, error };\n}\n\nexport type CopyShortLinkPhase = \"idle\" | \"minting\" | \"copied\" | \"error\";\n\nexport interface UseCopyShortLinkOptions\n extends Omit<MintShortLinkOptions, \"path\"> {\n /**\n * Path to shorten. Omit for \"the current page at click time\" (pathname +\n * query + hash — the exact configured view).\n */\n path?: string;\n /** Called with the short URL after it lands on the clipboard. */\n onCopied?: (url: string) => void;\n onError?: (error: string) => void;\n}\n\nexport interface UseCopyShortLinkResult {\n /** Mint (cached per path) + copy to the clipboard. The whole flow. */\n copy: () => Promise<{ ok: boolean; url?: string; error?: string }>;\n phase: CopyShortLinkPhase;\n /** The last successfully copied URL. */\n lastUrl: string | null;\n}\n\n/**\n * The headless whole-flow hook: mint (cached per path), clipboard write, and a\n * self-resetting phase for feedback. Every piece of chrome — the buttons below,\n * a menu row, a context-menu item — sits on this.\n */\nexport function useCopyShortLink(\n client: ShortLinkClient,\n options: UseCopyShortLinkOptions,\n): UseCopyShortLinkResult {\n const [phase, setPhase] = useState<CopyShortLinkPhase>(\"idle\");\n const [lastUrl, setLastUrl] = useState<string | null>(null);\n const cache = useRef<Map<string, MintShortLinkResult>>(new Map());\n const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const { path, onCopied, onError, ...mintOptions } = options;\n\n const flash = useCallback((next: \"copied\" | \"error\") => {\n setPhase(next);\n if (resetTimer.current) clearTimeout(resetTimer.current);\n resetTimer.current = setTimeout(() => setPhase(\"idle\"), 2000);\n }, []);\n\n const copy = useCallback(async () => {\n const targetPath = path ?? currentAppPath();\n if (!targetPath) {\n flash(\"error\");\n onError?.(\"No path to shorten\");\n return { ok: false, error: \"No path to shorten\" };\n }\n let result = cache.current.get(targetPath);\n if (!result?.ok) {\n setPhase(\"minting\");\n result = await mintShortLink(client, { ...mintOptions, path: targetPath });\n cache.current.set(targetPath, result);\n }\n if (!result.ok) {\n flash(\"error\");\n onError?.(result.error);\n return { ok: false, error: result.error };\n }\n const copied = await writeClipboard(result.url);\n if (!copied) {\n flash(\"error\");\n onError?.(\"Could not write to the clipboard\");\n return { ok: false, error: \"Could not write to the clipboard\" };\n }\n setLastUrl(result.url);\n flash(\"copied\");\n onCopied?.(result.url);\n return { ok: true, url: result.url };\n }, [\n client,\n path,\n mintOptions.organizationId,\n mintOptions.expiresAt,\n mintOptions.origin,\n flash,\n onCopied,\n onError,\n ]);\n\n return { copy, phase, lastUrl };\n}\n\nexport interface CopyShortLinkButtonProps extends UseCopyShortLinkOptions {\n /** Your Supabase client (anything with a compatible `.rpc`). */\n client: ShortLinkClient;\n /** Button label; default \"Copy short link\". */\n label?: string;\n className?: string;\n}\n\n/**\n * One click: mint (first time only), copy the short URL, confirm inline.\n * The mint is lazy — no short-link row exists until someone actually asks\n * for the link. With no `path`, shortens the current page at click time.\n */\nexport function CopyShortLinkButton({\n client,\n label = \"Copy short link\",\n className,\n ...options\n}: CopyShortLinkButtonProps) {\n const { copy, phase } = useCopyShortLink(client, options);\n\n const text =\n phase === \"copied\"\n ? \"Copied\"\n : phase === \"error\"\n ? \"Copy failed\"\n : phase === \"minting\"\n ? \"Creating…\"\n : label;\n\n return (\n <button\n type=\"button\"\n onClick={copy}\n disabled={phase === \"minting\"}\n aria-live=\"polite\"\n className={\n \"inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 \" +\n \"text-sm font-medium text-foreground hover:bg-muted disabled:opacity-60 \" +\n (phase === \"error\" ? \"text-destructive \" : \"\") +\n (className ?? \"\")\n }\n >\n {text}\n </button>\n );\n}\n\nexport type CopyPageShortLinkButtonProps = Omit<CopyShortLinkButtonProps, \"path\">;\n\n/**\n * \"Copy a short link to THIS page\" — the zero-thought affordance. The path is\n * read at click time (pathname + query + hash), so whatever the person has\n * configured — sorts, filters, hidden columns, a hash target — is exactly what\n * the recipient opens.\n */\nexport function CopyPageShortLinkButton(props: CopyPageShortLinkButtonProps) {\n return <CopyShortLinkButton {...props} />;\n}\n","/**\n * @ai-matrx/kit/short-link — the platform short-link CONTRACT, in one place.\n *\n * The primitive itself lives in the database (migration `0557_platform_short_links`):\n * `platform.short_links` holds `token → same-app path`, org-scoped and expiring;\n * `platform.create_short_link` mints (server-side only); `public.resolve_short_link`\n * resolves (anon-callable; answers with a PATH, never content — the target route's\n * own auth gates everything). The frontend redirect route is `/r/[token]`\n * (matrx-frontend `app/(public)/r/[token]`), and the notification spine mints per\n * SMS leg (aidream `services/notifications/short_links.py`).\n *\n * This module is the shared vocabulary every client needs to speak about those\n * tokens — alphabet, length, validation, and the URL shape — so no consumer ever\n * re-declares it. 🚨 `SHORT_LINK_TOKEN_ALPHABET` and `SHORT_LINK_TOKEN_LENGTH`\n * mirror `platform.create_short_link` EXACTLY; change one and you change both.\n *\n * 🚨 A short link is a URL, not a permission. The primitive that grants\n * anonymous access to content is `platform.share_links` (`/s/[token]`, 64-hex\n * tokens) — a different system, deliberately. Never shorten by minting a share\n * link, and never share by minting a short link.\n *\n * Pure logic — no DOM, no React, no network; safe in Server Components, route\n * handlers, workers, and Node scripts.\n */\n\n/**\n * 32 characters: digits 2–9 plus a–z minus `l` and `o`. 256 % 32 = 0, so the\n * mint maps random bytes without modulo bias; lowercase-only survives channels\n * that case-mangle (the resolver lowercases before lookup); the ambiguous\n * glyphs (`0/o`, `1/l`) never appear.\n */\nexport const SHORT_LINK_TOKEN_ALPHABET = \"23456789abcdefghijkmnpqrstuvwxyz\";\n\n/** 10 characters × 5 bits = 50 bits — unguessable at any realistic probe rate. */\nexport const SHORT_LINK_TOKEN_LENGTH = 10;\n\n/** The resolve route on the app origin (matrx-frontend `app/(public)/r/[token]`). */\nexport const SHORT_LINK_PATH_PREFIX = \"/r/\";\n\nconst TOKEN_RE = new RegExp(`^[${SHORT_LINK_TOKEN_ALPHABET}]{${SHORT_LINK_TOKEN_LENGTH}}$`);\n\n/**\n * Trim, lowercase, and shape-check a candidate token. Returns the normalized\n * token, or `null` for anything outside the contract — the resolver would\n * refuse it anyway, so callers can skip the round trip.\n */\nexport function normalizeShortLinkToken(candidate: string | null | undefined): string | null {\n const token = (candidate ?? \"\").trim().toLowerCase();\n return TOKEN_RE.test(token) ? token : null;\n}\n\n/** Whether a string is a well-formed short-link token (after normalization). */\nexport function isShortLinkToken(candidate: string | null | undefined): boolean {\n return normalizeShortLinkToken(candidate) !== null;\n}\n\n/** The app-relative path a token resolves at: `/r/<token>`. */\nexport function shortLinkPath(token: string): string {\n const normalized = normalizeShortLinkToken(token);\n if (!normalized) {\n throw new Error(\n `shortLinkPath: ${JSON.stringify(token)} is not a short-link token ` +\n `(${SHORT_LINK_TOKEN_LENGTH} chars of \"${SHORT_LINK_TOKEN_ALPHABET}\")`,\n );\n }\n return `${SHORT_LINK_PATH_PREFIX}${normalized}`;\n}\n\n/**\n * The absolute short URL for a token on a given origin, e.g.\n * `shortLinkUrl(\"https://app.aimatrx.com\", token)` → `https://app.aimatrx.com/r/<token>`.\n */\nexport function shortLinkUrl(origin: string, token: string): string {\n return `${origin.replace(/\\/+$/, \"\")}${shortLinkPath(token)}`;\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// The client half — the WHOLE mint/resolve logic, so a consumer adds nothing.\n//\n// Pass your Supabase client (or anything with a compatible `.rpc`) and you are\n// done: `mintShortLink(supabase, { path, organizationId })` calls the platform's\n// authenticated mint door (`public.shorten_app_url`, org-membership-gated) and\n// hands back the finished short URL; `resolveShortLinkPath(supabase, token)`\n// calls the anon resolver (`public.resolve_short_link`). No app writes its own\n// shortener logic, ever — the only thing outside this module is the shared\n// database that stores the tokens, which is exactly where shared state lives.\n// ─────────────────────────────────────────────────────────────────────────────\n\n/** Anything with a Supabase-shaped `.rpc` — pass the Supabase client itself. */\nexport interface ShortLinkClient {\n rpc(\n fn: string,\n args?: Record<string, unknown>,\n ): PromiseLike<{ data: unknown; error: { message: string } | null }>;\n}\n\nexport interface MintShortLinkOptions {\n /** Same-app path to shorten (must start with a single `/`). */\n path: string;\n /** The organization the link belongs to — the caller must be a member. */\n organizationId: string;\n /** ISO timestamp; the platform default (365 days) applies when omitted. */\n expiresAt?: string;\n /** Origin for the returned URL; defaults to `window.location.origin`. */\n origin?: string;\n}\n\nexport type MintShortLinkResult =\n | { ok: true; token: string; path: string; url: string }\n | { ok: false; error: string };\n\nfunction resolveOrigin(origin?: string): string | null {\n if (origin) return origin;\n if (typeof window !== \"undefined\" && window.location?.origin) {\n return window.location.origin;\n }\n return null;\n}\n\n/** Mint a short link through the platform's authenticated mint door. */\nexport async function mintShortLink(\n client: ShortLinkClient,\n options: MintShortLinkOptions,\n): Promise<MintShortLinkResult> {\n if (!options.path.startsWith(\"/\") || options.path.startsWith(\"//\")) {\n return { ok: false, error: \"Only a same-app path (starting with a single '/') can be shortened\" };\n }\n const origin = resolveOrigin(options.origin);\n if (!origin) {\n return { ok: false, error: \"No origin: pass options.origin outside a browser\" };\n }\n const { data, error } = await client.rpc(\"shorten_app_url\", {\n p_path: options.path,\n p_organization_id: options.organizationId,\n ...(options.expiresAt ? { p_expires_at: options.expiresAt } : {}),\n });\n if (error) return { ok: false, error: error.message };\n const result = data as { ok?: boolean; token?: string; error?: string } | null;\n const token = result?.ok ? normalizeShortLinkToken(result.token) : null;\n if (!token) {\n return { ok: false, error: result?.error ?? \"The mint door returned no token\" };\n }\n return { ok: true, token, path: shortLinkPath(token), url: shortLinkUrl(origin, token) };\n}\n\nexport type ResolveShortLinkResult =\n | { ok: true; targetPath: string }\n | { ok: false; transportError?: string };\n\n/**\n * Resolve a short token to its target path through the anon resolver.\n * `{ok:false}` covers invalid, unknown, and expired identically — the platform\n * deliberately does not distinguish them. A TRANSPORT failure (the RPC itself\n * errored) additionally carries `transportError`: a gateway outage is not\n * \"this link is gone\", and a caller rendering a 404 must check for it first.\n */\nexport async function resolveShortLinkPath(\n client: ShortLinkClient,\n token: string,\n): Promise<ResolveShortLinkResult> {\n const normalized = normalizeShortLinkToken(token);\n if (!normalized) return { ok: false };\n const { data, error } = await client.rpc(\"resolve_short_link\", { p_token: normalized });\n if (error) return { ok: false, transportError: error.message };\n const result = data as { ok?: boolean; target_path?: string } | null;\n const targetPath = result?.ok ? result.target_path : undefined;\n if (!targetPath || !targetPath.startsWith(\"/\") || targetPath.startsWith(\"//\")) {\n return { ok: false };\n }\n return { ok: true, targetPath };\n}\n\n/**\n * The current page as a shortenable same-app path — pathname + query + hash,\n * exactly the \"this view, as I have it configured\" URL (sorts, filters, hidden\n * columns riding in the query string all survive). `null` outside a browser.\n */\nexport function currentAppPath(): string | null {\n if (typeof window === \"undefined\" || !window.location) return null;\n const { pathname, search, hash } = window.location;\n if (!pathname.startsWith(\"/\") || pathname.startsWith(\"//\")) return null;\n return `${pathname}${search}${hash}`;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/short-link-react.tsx","../src/short-link.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/short-link-react — the drop-in short-link UI, headless included.\n *\n * The five-minute story: import the button, hand it your Supabase client, done.\n * The mint call (the org-gated `shorten_app_url` door), the URL shape, the\n * clipboard write, and the state feedback all live HERE — a consuming app\n * writes zero shortener logic.\n *\n * <CopyShortLinkButton client={supabase} path={`/notes/${id}`} organizationId={orgId} />\n * <CopyPageShortLinkButton client={supabase} organizationId={orgId} /> // THIS page, query string and all\n *\n * Custom chrome (a menu row, a context-menu item, an icon button)? Use the\n * headless hook and keep only the markup:\n *\n * const { copy, phase } = useCopyShortLink(supabase, { organizationId });\n * <button onClick={copy}>{phase === \"copied\" ? \"Copied\" : \"Copy short link\"}</button>\n *\n * Omitting `path` means \"the current page at the moment of the click\" —\n * pathname + query + hash, so a sorted/filtered/column-configured view\n * shortens to exactly that view.\n *\n * Runtime deps of this subpath: none beyond React.\n * Styling is the Matrx Tailwind semantic-token vocabulary; override via\n * `className` (the class string is appended).\n */\n\nimport { useCallback, useRef, useState } from \"react\";\n\nimport {\n currentAppPath,\n mintShortLink,\n type MintShortLinkOptions,\n type MintShortLinkResult,\n type ShortLinkClient,\n} from \"./short-link\";\n\nasync function writeClipboard(text: string): Promise<boolean> {\n try {\n await navigator.clipboard.writeText(text);\n return true;\n } catch {\n // Fall through — sandboxed webviews and permission-denied contexts land\n // here; the selection/execCommand path still works on a user gesture.\n }\n try {\n const scratch = document.createElement(\"textarea\");\n scratch.value = text;\n scratch.setAttribute(\"readonly\", \"\");\n scratch.style.position = \"fixed\";\n scratch.style.opacity = \"0\";\n document.body.appendChild(scratch);\n scratch.select();\n const copied = document.execCommand(\"copy\");\n scratch.remove();\n return copied;\n } catch {\n return false;\n }\n}\n\nexport interface UseShortLinkResult {\n /** Mint (once) and return the short URL; re-calls return the cached mint. */\n mint: () => Promise<MintShortLinkResult>;\n /** The minted URL, once `mint` has succeeded. */\n url: string | null;\n minting: boolean;\n error: string | null;\n}\n\n/** Mint-on-demand with caching — a fixed path known at render time. */\nexport function useShortLink(\n client: ShortLinkClient,\n options: MintShortLinkOptions,\n): UseShortLinkResult {\n const [url, setUrl] = useState<string | null>(null);\n const [minting, setMinting] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const cached = useRef<MintShortLinkResult | null>(null);\n\n const mint = useCallback(async (): Promise<MintShortLinkResult> => {\n if (cached.current?.ok) return cached.current;\n setMinting(true);\n setError(null);\n try {\n const result = await mintShortLink(client, options);\n cached.current = result;\n if (result.ok) {\n setUrl(result.url);\n } else {\n setError(result.error);\n }\n return result;\n } finally {\n setMinting(false);\n }\n }, [client, options.path, options.organizationId, options.expiresAt, options.origin]);\n\n return { mint, url, minting, error };\n}\n\nexport type CopyShortLinkPhase = \"idle\" | \"minting\" | \"copied\" | \"error\";\n\nexport interface UseCopyShortLinkOptions\n extends Omit<MintShortLinkOptions, \"path\"> {\n /**\n * Path to shorten. Omit for \"the current page at click time\" (pathname +\n * query + hash — the exact configured view).\n */\n path?: string;\n /** Called with the short URL after it lands on the clipboard. */\n onCopied?: (url: string) => void;\n onError?: (error: string) => void;\n}\n\nexport interface UseCopyShortLinkResult {\n /** Mint (cached per path) + copy to the clipboard. The whole flow. */\n copy: () => Promise<{ ok: boolean; url?: string; error?: string }>;\n phase: CopyShortLinkPhase;\n /** The last successfully copied URL. */\n lastUrl: string | null;\n}\n\n/**\n * The headless whole-flow hook: mint (cached per path), clipboard write, and a\n * self-resetting phase for feedback. Every piece of chrome — the buttons below,\n * a menu row, a context-menu item — sits on this.\n */\nexport function useCopyShortLink(\n client: ShortLinkClient,\n options: UseCopyShortLinkOptions,\n): UseCopyShortLinkResult {\n const [phase, setPhase] = useState<CopyShortLinkPhase>(\"idle\");\n const [lastUrl, setLastUrl] = useState<string | null>(null);\n const cache = useRef<Map<string, MintShortLinkResult>>(new Map());\n const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const { path, onCopied, onError, ...mintOptions } = options;\n\n const flash = useCallback((next: \"copied\" | \"error\") => {\n setPhase(next);\n if (resetTimer.current) clearTimeout(resetTimer.current);\n resetTimer.current = setTimeout(() => setPhase(\"idle\"), 2000);\n }, []);\n\n const copy = useCallback(async () => {\n const targetPath = path ?? currentAppPath();\n if (!targetPath) {\n flash(\"error\");\n onError?.(\"No path to shorten\");\n return { ok: false, error: \"No path to shorten\" };\n }\n let result = cache.current.get(targetPath);\n if (!result?.ok) {\n setPhase(\"minting\");\n result = await mintShortLink(client, { ...mintOptions, path: targetPath });\n cache.current.set(targetPath, result);\n }\n if (!result.ok) {\n flash(\"error\");\n onError?.(result.error);\n return { ok: false, error: result.error };\n }\n const copied = await writeClipboard(result.url);\n if (!copied) {\n flash(\"error\");\n onError?.(\"Could not write to the clipboard\");\n return { ok: false, error: \"Could not write to the clipboard\" };\n }\n setLastUrl(result.url);\n flash(\"copied\");\n onCopied?.(result.url);\n return { ok: true, url: result.url };\n }, [\n client,\n path,\n mintOptions.organizationId,\n mintOptions.expiresAt,\n mintOptions.origin,\n flash,\n onCopied,\n onError,\n ]);\n\n return { copy, phase, lastUrl };\n}\n\nexport interface CopyShortLinkButtonProps extends UseCopyShortLinkOptions {\n /** Your Supabase client (anything with a compatible `.rpc`). */\n client: ShortLinkClient;\n /** Button label; default \"Copy short link\". */\n label?: string;\n className?: string;\n}\n\n/**\n * One click: mint (first time only), copy the short URL, confirm inline.\n * The mint is lazy — no short-link row exists until someone actually asks\n * for the link. With no `path`, shortens the current page at click time.\n */\nexport function CopyShortLinkButton({\n client,\n label = \"Copy short link\",\n className,\n ...options\n}: CopyShortLinkButtonProps) {\n const { copy, phase } = useCopyShortLink(client, options);\n\n const text =\n phase === \"copied\"\n ? \"Copied\"\n : phase === \"error\"\n ? \"Copy failed\"\n : phase === \"minting\"\n ? \"Creating…\"\n : label;\n\n return (\n <button\n type=\"button\"\n onClick={copy}\n disabled={phase === \"minting\"}\n aria-live=\"polite\"\n className={\n \"inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 \" +\n \"text-sm font-medium text-foreground hover:bg-muted disabled:opacity-60 \" +\n (phase === \"error\" ? \"text-destructive \" : \"\") +\n (className ?? \"\")\n }\n >\n {text}\n </button>\n );\n}\n\nexport type CopyPageShortLinkButtonProps = Omit<CopyShortLinkButtonProps, \"path\">;\n\n/**\n * \"Copy a short link to THIS page\" — the zero-thought affordance. The path is\n * read at click time (pathname + query + hash), so whatever the person has\n * configured — sorts, filters, hidden columns, a hash target — is exactly what\n * the recipient opens.\n */\nexport function CopyPageShortLinkButton(props: CopyPageShortLinkButtonProps) {\n return <CopyShortLinkButton {...props} />;\n}\n","/**\n * @ai-matrx/kit/short-link — the platform short-link CONTRACT, in one place.\n *\n * The primitive itself lives in the database (migration `0557_platform_short_links`):\n * `platform.short_links` holds `token → same-app path`, org-scoped and expiring;\n * `platform.create_short_link` mints (server-side only); `public.resolve_short_link`\n * resolves (anon-callable; answers with a PATH, never content — the target route's\n * own auth gates everything). The frontend redirect route is `/r/[token]`\n * (matrx-frontend `app/(public)/r/[token]`), and the notification spine mints per\n * SMS leg (aidream `services/notifications/short_links.py`).\n *\n * This module is the shared vocabulary every client needs to speak about those\n * tokens — alphabet, length, validation, and the URL shape — so no consumer ever\n * re-declares it. 🚨 `SHORT_LINK_TOKEN_ALPHABET` and `SHORT_LINK_TOKEN_LENGTH`\n * mirror `platform.create_short_link` EXACTLY; change one and you change both.\n *\n * 🚨 A short link is a URL, not a permission. The primitive that grants\n * anonymous access to content is `platform.share_links` (`/s/[token]`, 64-hex\n * tokens) — a different system, deliberately. Never shorten by minting a share\n * link, and never share by minting a short link.\n *\n * Pure logic — no DOM, no React, no network; safe in Server Components, route\n * handlers, workers, and Node scripts.\n */\n\n/**\n * 32 characters: digits 2–9 plus a–z minus `l` and `o`. 256 % 32 = 0, so the\n * mint maps random bytes without modulo bias; lowercase-only survives channels\n * that case-mangle (the resolver lowercases before lookup); the ambiguous\n * glyphs (`0/o`, `1/l`) never appear.\n */\nexport const SHORT_LINK_TOKEN_ALPHABET = \"23456789abcdefghijkmnpqrstuvwxyz\";\n\n/** 10 characters × 5 bits = 50 bits — unguessable at any realistic probe rate. */\nexport const SHORT_LINK_TOKEN_LENGTH = 10;\n\n/** The resolve route on the app origin (matrx-frontend `app/(public)/r/[token]`). */\nexport const SHORT_LINK_PATH_PREFIX = \"/r/\";\n\nconst TOKEN_RE = new RegExp(`^[${SHORT_LINK_TOKEN_ALPHABET}]{${SHORT_LINK_TOKEN_LENGTH}}$`);\n\n/**\n * Trim, lowercase, and shape-check a candidate token. Returns the normalized\n * token, or `null` for anything outside the contract — the resolver would\n * refuse it anyway, so callers can skip the round trip.\n */\nexport function normalizeShortLinkToken(candidate: string | null | undefined): string | null {\n const token = (candidate ?? \"\").trim().toLowerCase();\n return TOKEN_RE.test(token) ? token : null;\n}\n\n/** Whether a string is a well-formed short-link token (after normalization). */\nexport function isShortLinkToken(candidate: string | null | undefined): boolean {\n return normalizeShortLinkToken(candidate) !== null;\n}\n\n/** The app-relative path a token resolves at: `/r/<token>`. */\nexport function shortLinkPath(token: string): string {\n const normalized = normalizeShortLinkToken(token);\n if (!normalized) {\n throw new Error(\n `shortLinkPath: ${JSON.stringify(token)} is not a short-link token ` +\n `(${SHORT_LINK_TOKEN_LENGTH} chars of \"${SHORT_LINK_TOKEN_ALPHABET}\")`,\n );\n }\n return `${SHORT_LINK_PATH_PREFIX}${normalized}`;\n}\n\n/**\n * The absolute short URL for a token on a given origin, e.g.\n * `shortLinkUrl(\"https://app.aimatrx.com\", token)` → `https://app.aimatrx.com/r/<token>`.\n */\nexport function shortLinkUrl(origin: string, token: string): string {\n return `${origin.replace(/\\/+$/, \"\")}${shortLinkPath(token)}`;\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// The client half — the WHOLE mint/resolve logic, so a consumer adds nothing.\n//\n// Pass your Supabase client (or anything with a compatible `.rpc`) and you are\n// done: `mintShortLink(supabase, { path, organizationId })` calls the platform's\n// authenticated mint door (`public.shorten_app_url`, org-membership-gated) and\n// hands back the finished short URL; `resolveShortLinkPath(supabase, token)`\n// calls the anon resolver (`public.resolve_short_link`). No app writes its own\n// shortener logic, ever — the only thing outside this module is the shared\n// database that stores the tokens, which is exactly where shared state lives.\n// ─────────────────────────────────────────────────────────────────────────────\n\n/** Anything with a Supabase-shaped `.rpc` — pass the Supabase client itself. */\nexport interface ShortLinkClient {\n rpc(\n fn: string,\n args?: Record<string, unknown>,\n ): PromiseLike<{ data: unknown; error: { message: string } | null }>;\n}\n\nexport interface MintShortLinkOptions {\n /** Same-app path to shorten (must start with a single `/`). */\n path: string;\n /** The organization the link belongs to — the caller must be a member. */\n organizationId: string;\n /** ISO timestamp; the platform default (365 days) applies when omitted. */\n expiresAt?: string;\n /** Origin for the returned URL; defaults to `window.location.origin`. */\n origin?: string;\n}\n\nexport type MintShortLinkResult =\n | { ok: true; token: string; path: string; url: string }\n | { ok: false; error: string };\n\nfunction resolveOrigin(origin?: string): string | null {\n if (origin) return origin;\n if (typeof window !== \"undefined\" && window.location?.origin) {\n return window.location.origin;\n }\n return null;\n}\n\n/** Mint a short link through the platform's authenticated mint door. */\nexport async function mintShortLink(\n client: ShortLinkClient,\n options: MintShortLinkOptions,\n): Promise<MintShortLinkResult> {\n if (!options.path.startsWith(\"/\") || options.path.startsWith(\"//\")) {\n return { ok: false, error: \"Only a same-app path (starting with a single '/') can be shortened\" };\n }\n const origin = resolveOrigin(options.origin);\n if (!origin) {\n return { ok: false, error: \"No origin: pass options.origin outside a browser\" };\n }\n const { data, error } = await client.rpc(\"shorten_app_url\", {\n p_path: options.path,\n p_organization_id: options.organizationId,\n ...(options.expiresAt ? { p_expires_at: options.expiresAt } : {}),\n });\n if (error) return { ok: false, error: error.message };\n const result = data as { ok?: boolean; token?: string; error?: string } | null;\n const token = result?.ok ? normalizeShortLinkToken(result.token) : null;\n if (!token) {\n return { ok: false, error: result?.error ?? \"The mint door returned no token\" };\n }\n return { ok: true, token, path: shortLinkPath(token), url: shortLinkUrl(origin, token) };\n}\n\nexport type ResolveShortLinkResult =\n | { ok: true; targetPath: string }\n | { ok: false; transportError?: string };\n\n/**\n * Resolve a short token to its target path through the anon resolver.\n * `{ok:false}` covers invalid, unknown, and expired identically — the platform\n * deliberately does not distinguish them. A TRANSPORT failure (the RPC itself\n * errored) additionally carries `transportError`: a gateway outage is not\n * \"this link is gone\", and a caller rendering a 404 must check for it first.\n */\nexport async function resolveShortLinkPath(\n client: ShortLinkClient,\n token: string,\n): Promise<ResolveShortLinkResult> {\n const normalized = normalizeShortLinkToken(token);\n if (!normalized) return { ok: false };\n const { data, error } = await client.rpc(\"resolve_short_link\", { p_token: normalized });\n if (error) return { ok: false, transportError: error.message };\n const result = data as { ok?: boolean; target_path?: string } | null;\n const targetPath = result?.ok ? result.target_path : undefined;\n if (!targetPath || !targetPath.startsWith(\"/\") || targetPath.startsWith(\"//\")) {\n return { ok: false };\n }\n return { ok: true, targetPath };\n}\n\n/**\n * The current page as a shortenable same-app path — pathname + query + hash,\n * exactly the \"this view, as I have it configured\" URL (sorts, filters, hidden\n * columns riding in the query string all survive). `null` outside a browser.\n */\nexport function currentAppPath(): string | null {\n if (typeof window === \"undefined\" || !window.location) return null;\n const { pathname, search, hash } = window.location;\n if (!pathname.startsWith(\"/\") || pathname.startsWith(\"//\")) return null;\n return `${pathname}${search}${hash}`;\n}\n"],"mappings":"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","names":[]}
@@ -65,6 +65,19 @@ async function writeClipboard(text) {
65
65
  try {
66
66
  await navigator.clipboard.writeText(text);
67
67
  return true;
68
+ } catch {
69
+ }
70
+ try {
71
+ const scratch = document.createElement("textarea");
72
+ scratch.value = text;
73
+ scratch.setAttribute("readonly", "");
74
+ scratch.style.position = "fixed";
75
+ scratch.style.opacity = "0";
76
+ document.body.appendChild(scratch);
77
+ scratch.select();
78
+ const copied = document.execCommand("copy");
79
+ scratch.remove();
80
+ return copied;
68
81
  } catch {
69
82
  return false;
70
83
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/short-link-react.tsx","../src/short-link.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/short-link-react — the drop-in short-link UI, headless included.\n *\n * The five-minute story: import the button, hand it your Supabase client, done.\n * The mint call (the org-gated `shorten_app_url` door), the URL shape, the\n * clipboard write, and the state feedback all live HERE — a consuming app\n * writes zero shortener logic.\n *\n * <CopyShortLinkButton client={supabase} path={`/notes/${id}`} organizationId={orgId} />\n * <CopyPageShortLinkButton client={supabase} organizationId={orgId} /> // THIS page, query string and all\n *\n * Custom chrome (a menu row, a context-menu item, an icon button)? Use the\n * headless hook and keep only the markup:\n *\n * const { copy, phase } = useCopyShortLink(supabase, { organizationId });\n * <button onClick={copy}>{phase === \"copied\" ? \"Copied\" : \"Copy short link\"}</button>\n *\n * Omitting `path` means \"the current page at the moment of the click\" —\n * pathname + query + hash, so a sorted/filtered/column-configured view\n * shortens to exactly that view.\n *\n * Runtime deps of this subpath: none beyond React.\n * Styling is the Matrx Tailwind semantic-token vocabulary; override via\n * `className` (the class string is appended).\n */\n\nimport { useCallback, useRef, useState } from \"react\";\n\nimport {\n currentAppPath,\n mintShortLink,\n type MintShortLinkOptions,\n type MintShortLinkResult,\n type ShortLinkClient,\n} from \"./short-link\";\n\nasync function writeClipboard(text: string): Promise<boolean> {\n try {\n await navigator.clipboard.writeText(text);\n return true;\n } catch {\n return false;\n }\n}\n\nexport interface UseShortLinkResult {\n /** Mint (once) and return the short URL; re-calls return the cached mint. */\n mint: () => Promise<MintShortLinkResult>;\n /** The minted URL, once `mint` has succeeded. */\n url: string | null;\n minting: boolean;\n error: string | null;\n}\n\n/** Mint-on-demand with caching — a fixed path known at render time. */\nexport function useShortLink(\n client: ShortLinkClient,\n options: MintShortLinkOptions,\n): UseShortLinkResult {\n const [url, setUrl] = useState<string | null>(null);\n const [minting, setMinting] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const cached = useRef<MintShortLinkResult | null>(null);\n\n const mint = useCallback(async (): Promise<MintShortLinkResult> => {\n if (cached.current?.ok) return cached.current;\n setMinting(true);\n setError(null);\n try {\n const result = await mintShortLink(client, options);\n cached.current = result;\n if (result.ok) {\n setUrl(result.url);\n } else {\n setError(result.error);\n }\n return result;\n } finally {\n setMinting(false);\n }\n }, [client, options.path, options.organizationId, options.expiresAt, options.origin]);\n\n return { mint, url, minting, error };\n}\n\nexport type CopyShortLinkPhase = \"idle\" | \"minting\" | \"copied\" | \"error\";\n\nexport interface UseCopyShortLinkOptions\n extends Omit<MintShortLinkOptions, \"path\"> {\n /**\n * Path to shorten. Omit for \"the current page at click time\" (pathname +\n * query + hash — the exact configured view).\n */\n path?: string;\n /** Called with the short URL after it lands on the clipboard. */\n onCopied?: (url: string) => void;\n onError?: (error: string) => void;\n}\n\nexport interface UseCopyShortLinkResult {\n /** Mint (cached per path) + copy to the clipboard. The whole flow. */\n copy: () => Promise<{ ok: boolean; url?: string; error?: string }>;\n phase: CopyShortLinkPhase;\n /** The last successfully copied URL. */\n lastUrl: string | null;\n}\n\n/**\n * The headless whole-flow hook: mint (cached per path), clipboard write, and a\n * self-resetting phase for feedback. Every piece of chrome — the buttons below,\n * a menu row, a context-menu item — sits on this.\n */\nexport function useCopyShortLink(\n client: ShortLinkClient,\n options: UseCopyShortLinkOptions,\n): UseCopyShortLinkResult {\n const [phase, setPhase] = useState<CopyShortLinkPhase>(\"idle\");\n const [lastUrl, setLastUrl] = useState<string | null>(null);\n const cache = useRef<Map<string, MintShortLinkResult>>(new Map());\n const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const { path, onCopied, onError, ...mintOptions } = options;\n\n const flash = useCallback((next: \"copied\" | \"error\") => {\n setPhase(next);\n if (resetTimer.current) clearTimeout(resetTimer.current);\n resetTimer.current = setTimeout(() => setPhase(\"idle\"), 2000);\n }, []);\n\n const copy = useCallback(async () => {\n const targetPath = path ?? currentAppPath();\n if (!targetPath) {\n flash(\"error\");\n onError?.(\"No path to shorten\");\n return { ok: false, error: \"No path to shorten\" };\n }\n let result = cache.current.get(targetPath);\n if (!result?.ok) {\n setPhase(\"minting\");\n result = await mintShortLink(client, { ...mintOptions, path: targetPath });\n cache.current.set(targetPath, result);\n }\n if (!result.ok) {\n flash(\"error\");\n onError?.(result.error);\n return { ok: false, error: result.error };\n }\n const copied = await writeClipboard(result.url);\n if (!copied) {\n flash(\"error\");\n onError?.(\"Could not write to the clipboard\");\n return { ok: false, error: \"Could not write to the clipboard\" };\n }\n setLastUrl(result.url);\n flash(\"copied\");\n onCopied?.(result.url);\n return { ok: true, url: result.url };\n }, [\n client,\n path,\n mintOptions.organizationId,\n mintOptions.expiresAt,\n mintOptions.origin,\n flash,\n onCopied,\n onError,\n ]);\n\n return { copy, phase, lastUrl };\n}\n\nexport interface CopyShortLinkButtonProps extends UseCopyShortLinkOptions {\n /** Your Supabase client (anything with a compatible `.rpc`). */\n client: ShortLinkClient;\n /** Button label; default \"Copy short link\". */\n label?: string;\n className?: string;\n}\n\n/**\n * One click: mint (first time only), copy the short URL, confirm inline.\n * The mint is lazy — no short-link row exists until someone actually asks\n * for the link. With no `path`, shortens the current page at click time.\n */\nexport function CopyShortLinkButton({\n client,\n label = \"Copy short link\",\n className,\n ...options\n}: CopyShortLinkButtonProps) {\n const { copy, phase } = useCopyShortLink(client, options);\n\n const text =\n phase === \"copied\"\n ? \"Copied\"\n : phase === \"error\"\n ? \"Copy failed\"\n : phase === \"minting\"\n ? \"Creating…\"\n : label;\n\n return (\n <button\n type=\"button\"\n onClick={copy}\n disabled={phase === \"minting\"}\n aria-live=\"polite\"\n className={\n \"inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 \" +\n \"text-sm font-medium text-foreground hover:bg-muted disabled:opacity-60 \" +\n (phase === \"error\" ? \"text-destructive \" : \"\") +\n (className ?? \"\")\n }\n >\n {text}\n </button>\n );\n}\n\nexport type CopyPageShortLinkButtonProps = Omit<CopyShortLinkButtonProps, \"path\">;\n\n/**\n * \"Copy a short link to THIS page\" — the zero-thought affordance. The path is\n * read at click time (pathname + query + hash), so whatever the person has\n * configured — sorts, filters, hidden columns, a hash target — is exactly what\n * the recipient opens.\n */\nexport function CopyPageShortLinkButton(props: CopyPageShortLinkButtonProps) {\n return <CopyShortLinkButton {...props} />;\n}\n","/**\n * @ai-matrx/kit/short-link — the platform short-link CONTRACT, in one place.\n *\n * The primitive itself lives in the database (migration `0557_platform_short_links`):\n * `platform.short_links` holds `token → same-app path`, org-scoped and expiring;\n * `platform.create_short_link` mints (server-side only); `public.resolve_short_link`\n * resolves (anon-callable; answers with a PATH, never content — the target route's\n * own auth gates everything). The frontend redirect route is `/r/[token]`\n * (matrx-frontend `app/(public)/r/[token]`), and the notification spine mints per\n * SMS leg (aidream `services/notifications/short_links.py`).\n *\n * This module is the shared vocabulary every client needs to speak about those\n * tokens — alphabet, length, validation, and the URL shape — so no consumer ever\n * re-declares it. 🚨 `SHORT_LINK_TOKEN_ALPHABET` and `SHORT_LINK_TOKEN_LENGTH`\n * mirror `platform.create_short_link` EXACTLY; change one and you change both.\n *\n * 🚨 A short link is a URL, not a permission. The primitive that grants\n * anonymous access to content is `platform.share_links` (`/s/[token]`, 64-hex\n * tokens) — a different system, deliberately. Never shorten by minting a share\n * link, and never share by minting a short link.\n *\n * Pure logic — no DOM, no React, no network; safe in Server Components, route\n * handlers, workers, and Node scripts.\n */\n\n/**\n * 32 characters: digits 2–9 plus a–z minus `l` and `o`. 256 % 32 = 0, so the\n * mint maps random bytes without modulo bias; lowercase-only survives channels\n * that case-mangle (the resolver lowercases before lookup); the ambiguous\n * glyphs (`0/o`, `1/l`) never appear.\n */\nexport const SHORT_LINK_TOKEN_ALPHABET = \"23456789abcdefghijkmnpqrstuvwxyz\";\n\n/** 10 characters × 5 bits = 50 bits — unguessable at any realistic probe rate. */\nexport const SHORT_LINK_TOKEN_LENGTH = 10;\n\n/** The resolve route on the app origin (matrx-frontend `app/(public)/r/[token]`). */\nexport const SHORT_LINK_PATH_PREFIX = \"/r/\";\n\nconst TOKEN_RE = new RegExp(`^[${SHORT_LINK_TOKEN_ALPHABET}]{${SHORT_LINK_TOKEN_LENGTH}}$`);\n\n/**\n * Trim, lowercase, and shape-check a candidate token. Returns the normalized\n * token, or `null` for anything outside the contract — the resolver would\n * refuse it anyway, so callers can skip the round trip.\n */\nexport function normalizeShortLinkToken(candidate: string | null | undefined): string | null {\n const token = (candidate ?? \"\").trim().toLowerCase();\n return TOKEN_RE.test(token) ? token : null;\n}\n\n/** Whether a string is a well-formed short-link token (after normalization). */\nexport function isShortLinkToken(candidate: string | null | undefined): boolean {\n return normalizeShortLinkToken(candidate) !== null;\n}\n\n/** The app-relative path a token resolves at: `/r/<token>`. */\nexport function shortLinkPath(token: string): string {\n const normalized = normalizeShortLinkToken(token);\n if (!normalized) {\n throw new Error(\n `shortLinkPath: ${JSON.stringify(token)} is not a short-link token ` +\n `(${SHORT_LINK_TOKEN_LENGTH} chars of \"${SHORT_LINK_TOKEN_ALPHABET}\")`,\n );\n }\n return `${SHORT_LINK_PATH_PREFIX}${normalized}`;\n}\n\n/**\n * The absolute short URL for a token on a given origin, e.g.\n * `shortLinkUrl(\"https://app.aimatrx.com\", token)` → `https://app.aimatrx.com/r/<token>`.\n */\nexport function shortLinkUrl(origin: string, token: string): string {\n return `${origin.replace(/\\/+$/, \"\")}${shortLinkPath(token)}`;\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// The client half — the WHOLE mint/resolve logic, so a consumer adds nothing.\n//\n// Pass your Supabase client (or anything with a compatible `.rpc`) and you are\n// done: `mintShortLink(supabase, { path, organizationId })` calls the platform's\n// authenticated mint door (`public.shorten_app_url`, org-membership-gated) and\n// hands back the finished short URL; `resolveShortLinkPath(supabase, token)`\n// calls the anon resolver (`public.resolve_short_link`). No app writes its own\n// shortener logic, ever — the only thing outside this module is the shared\n// database that stores the tokens, which is exactly where shared state lives.\n// ─────────────────────────────────────────────────────────────────────────────\n\n/** Anything with a Supabase-shaped `.rpc` — pass the Supabase client itself. */\nexport interface ShortLinkClient {\n rpc(\n fn: string,\n args?: Record<string, unknown>,\n ): PromiseLike<{ data: unknown; error: { message: string } | null }>;\n}\n\nexport interface MintShortLinkOptions {\n /** Same-app path to shorten (must start with a single `/`). */\n path: string;\n /** The organization the link belongs to — the caller must be a member. */\n organizationId: string;\n /** ISO timestamp; the platform default (365 days) applies when omitted. */\n expiresAt?: string;\n /** Origin for the returned URL; defaults to `window.location.origin`. */\n origin?: string;\n}\n\nexport type MintShortLinkResult =\n | { ok: true; token: string; path: string; url: string }\n | { ok: false; error: string };\n\nfunction resolveOrigin(origin?: string): string | null {\n if (origin) return origin;\n if (typeof window !== \"undefined\" && window.location?.origin) {\n return window.location.origin;\n }\n return null;\n}\n\n/** Mint a short link through the platform's authenticated mint door. */\nexport async function mintShortLink(\n client: ShortLinkClient,\n options: MintShortLinkOptions,\n): Promise<MintShortLinkResult> {\n if (!options.path.startsWith(\"/\") || options.path.startsWith(\"//\")) {\n return { ok: false, error: \"Only a same-app path (starting with a single '/') can be shortened\" };\n }\n const origin = resolveOrigin(options.origin);\n if (!origin) {\n return { ok: false, error: \"No origin: pass options.origin outside a browser\" };\n }\n const { data, error } = await client.rpc(\"shorten_app_url\", {\n p_path: options.path,\n p_organization_id: options.organizationId,\n ...(options.expiresAt ? { p_expires_at: options.expiresAt } : {}),\n });\n if (error) return { ok: false, error: error.message };\n const result = data as { ok?: boolean; token?: string; error?: string } | null;\n const token = result?.ok ? normalizeShortLinkToken(result.token) : null;\n if (!token) {\n return { ok: false, error: result?.error ?? \"The mint door returned no token\" };\n }\n return { ok: true, token, path: shortLinkPath(token), url: shortLinkUrl(origin, token) };\n}\n\nexport type ResolveShortLinkResult =\n | { ok: true; targetPath: string }\n | { ok: false; transportError?: string };\n\n/**\n * Resolve a short token to its target path through the anon resolver.\n * `{ok:false}` covers invalid, unknown, and expired identically — the platform\n * deliberately does not distinguish them. A TRANSPORT failure (the RPC itself\n * errored) additionally carries `transportError`: a gateway outage is not\n * \"this link is gone\", and a caller rendering a 404 must check for it first.\n */\nexport async function resolveShortLinkPath(\n client: ShortLinkClient,\n token: string,\n): Promise<ResolveShortLinkResult> {\n const normalized = normalizeShortLinkToken(token);\n if (!normalized) return { ok: false };\n const { data, error } = await client.rpc(\"resolve_short_link\", { p_token: normalized });\n if (error) return { ok: false, transportError: error.message };\n const result = data as { ok?: boolean; target_path?: string } | null;\n const targetPath = result?.ok ? result.target_path : undefined;\n if (!targetPath || !targetPath.startsWith(\"/\") || targetPath.startsWith(\"//\")) {\n return { ok: false };\n }\n return { ok: true, targetPath };\n}\n\n/**\n * The current page as a shortenable same-app path — pathname + query + hash,\n * exactly the \"this view, as I have it configured\" URL (sorts, filters, hidden\n * columns riding in the query string all survive). `null` outside a browser.\n */\nexport function currentAppPath(): string | null {\n if (typeof window === \"undefined\" || !window.location) return null;\n const { pathname, search, hash } = window.location;\n if (!pathname.startsWith(\"/\") || pathname.startsWith(\"//\")) return null;\n return `${pathname}${search}${hash}`;\n}\n"],"mappings":";;;AA0BA,SAAS,aAAa,QAAQ,gBAAgB;;;ACKvC,IAAM,4BAA4B;AAGlC,IAAM,0BAA0B;AAGhC,IAAM,yBAAyB;AAEtC,IAAM,WAAW,IAAI,OAAO,KAAK,yBAAyB,KAAK,uBAAuB,IAAI;AAOnF,SAAS,wBAAwB,WAAqD;AAC3F,QAAM,SAAS,aAAa,IAAI,KAAK,EAAE,YAAY;AACnD,SAAO,SAAS,KAAK,KAAK,IAAI,QAAQ;AACxC;AAQO,SAAS,cAAc,OAAuB;AACnD,QAAM,aAAa,wBAAwB,KAAK;AAChD,MAAI,CAAC,YAAY;AACf,UAAM,IAAI;AAAA,MACR,kBAAkB,KAAK,UAAU,KAAK,CAAC,+BACjC,uBAAuB,cAAc,yBAAyB;AAAA,IACtE;AAAA,EACF;AACA,SAAO,GAAG,sBAAsB,GAAG,UAAU;AAC/C;AAMO,SAAS,aAAa,QAAgB,OAAuB;AAClE,SAAO,GAAG,OAAO,QAAQ,QAAQ,EAAE,CAAC,GAAG,cAAc,KAAK,CAAC;AAC7D;AAqCA,SAAS,cAAc,QAAgC;AACrD,MAAI,OAAQ,QAAO;AACnB,MAAI,OAAO,WAAW,eAAe,OAAO,UAAU,QAAQ;AAC5D,WAAO,OAAO,SAAS;AAAA,EACzB;AACA,SAAO;AACT;AAGA,eAAsB,cACpB,QACA,SAC8B;AAC9B,MAAI,CAAC,QAAQ,KAAK,WAAW,GAAG,KAAK,QAAQ,KAAK,WAAW,IAAI,GAAG;AAClE,WAAO,EAAE,IAAI,OAAO,OAAO,qEAAqE;AAAA,EAClG;AACA,QAAM,SAAS,cAAc,QAAQ,MAAM;AAC3C,MAAI,CAAC,QAAQ;AACX,WAAO,EAAE,IAAI,OAAO,OAAO,mDAAmD;AAAA,EAChF;AACA,QAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,IAAI,mBAAmB;AAAA,IAC1D,QAAQ,QAAQ;AAAA,IAChB,mBAAmB,QAAQ;AAAA,IAC3B,GAAI,QAAQ,YAAY,EAAE,cAAc,QAAQ,UAAU,IAAI,CAAC;AAAA,EACjE,CAAC;AACD,MAAI,MAAO,QAAO,EAAE,IAAI,OAAO,OAAO,MAAM,QAAQ;AACpD,QAAM,SAAS;AACf,QAAM,QAAQ,QAAQ,KAAK,wBAAwB,OAAO,KAAK,IAAI;AACnE,MAAI,CAAC,OAAO;AACV,WAAO,EAAE,IAAI,OAAO,OAAO,QAAQ,SAAS,kCAAkC;AAAA,EAChF;AACA,SAAO,EAAE,IAAI,MAAM,OAAO,MAAM,cAAc,KAAK,GAAG,KAAK,aAAa,QAAQ,KAAK,EAAE;AACzF;AAkCO,SAAS,iBAAgC;AAC9C,MAAI,OAAO,WAAW,eAAe,CAAC,OAAO,SAAU,QAAO;AAC9D,QAAM,EAAE,UAAU,QAAQ,KAAK,IAAI,OAAO;AAC1C,MAAI,CAAC,SAAS,WAAW,GAAG,KAAK,SAAS,WAAW,IAAI,EAAG,QAAO;AACnE,SAAO,GAAG,QAAQ,GAAG,MAAM,GAAG,IAAI;AACpC;;;ADmBI;AArKJ,eAAe,eAAe,MAAgC;AAC5D,MAAI;AACF,UAAM,UAAU,UAAU,UAAU,IAAI;AACxC,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAYO,SAAS,aACd,QACA,SACoB;AACpB,QAAM,CAAC,KAAK,MAAM,IAAI,SAAwB,IAAI;AAClD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAC5C,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAwB,IAAI;AACtD,QAAM,SAAS,OAAmC,IAAI;AAEtD,QAAM,OAAO,YAAY,YAA0C;AACjE,QAAI,OAAO,SAAS,GAAI,QAAO,OAAO;AACtC,eAAW,IAAI;AACf,aAAS,IAAI;AACb,QAAI;AACF,YAAM,SAAS,MAAM,cAAc,QAAQ,OAAO;AAClD,aAAO,UAAU;AACjB,UAAI,OAAO,IAAI;AACb,eAAO,OAAO,GAAG;AAAA,MACnB,OAAO;AACL,iBAAS,OAAO,KAAK;AAAA,MACvB;AACA,aAAO;AAAA,IACT,UAAE;AACA,iBAAW,KAAK;AAAA,IAClB;AAAA,EACF,GAAG,CAAC,QAAQ,QAAQ,MAAM,QAAQ,gBAAgB,QAAQ,WAAW,QAAQ,MAAM,CAAC;AAEpF,SAAO,EAAE,MAAM,KAAK,SAAS,MAAM;AACrC;AA6BO,SAAS,iBACd,QACA,SACwB;AACxB,QAAM,CAAC,OAAO,QAAQ,IAAI,SAA6B,MAAM;AAC7D,QAAM,CAAC,SAAS,UAAU,IAAI,SAAwB,IAAI;AAC1D,QAAM,QAAQ,OAAyC,oBAAI,IAAI,CAAC;AAChE,QAAM,aAAa,OAA6C,IAAI;AACpE,QAAM,EAAE,MAAM,UAAU,SAAS,GAAG,YAAY,IAAI;AAEpD,QAAM,QAAQ,YAAY,CAAC,SAA6B;AACtD,aAAS,IAAI;AACb,QAAI,WAAW,QAAS,cAAa,WAAW,OAAO;AACvD,eAAW,UAAU,WAAW,MAAM,SAAS,MAAM,GAAG,GAAI;AAAA,EAC9D,GAAG,CAAC,CAAC;AAEL,QAAM,OAAO,YAAY,YAAY;AACnC,UAAM,aAAa,QAAQ,eAAe;AAC1C,QAAI,CAAC,YAAY;AACf,YAAM,OAAO;AACb,gBAAU,oBAAoB;AAC9B,aAAO,EAAE,IAAI,OAAO,OAAO,qBAAqB;AAAA,IAClD;AACA,QAAI,SAAS,MAAM,QAAQ,IAAI,UAAU;AACzC,QAAI,CAAC,QAAQ,IAAI;AACf,eAAS,SAAS;AAClB,eAAS,MAAM,cAAc,QAAQ,EAAE,GAAG,aAAa,MAAM,WAAW,CAAC;AACzE,YAAM,QAAQ,IAAI,YAAY,MAAM;AAAA,IACtC;AACA,QAAI,CAAC,OAAO,IAAI;AACd,YAAM,OAAO;AACb,gBAAU,OAAO,KAAK;AACtB,aAAO,EAAE,IAAI,OAAO,OAAO,OAAO,MAAM;AAAA,IAC1C;AACA,UAAM,SAAS,MAAM,eAAe,OAAO,GAAG;AAC9C,QAAI,CAAC,QAAQ;AACX,YAAM,OAAO;AACb,gBAAU,kCAAkC;AAC5C,aAAO,EAAE,IAAI,OAAO,OAAO,mCAAmC;AAAA,IAChE;AACA,eAAW,OAAO,GAAG;AACrB,UAAM,QAAQ;AACd,eAAW,OAAO,GAAG;AACrB,WAAO,EAAE,IAAI,MAAM,KAAK,OAAO,IAAI;AAAA,EACrC,GAAG;AAAA,IACD;AAAA,IACA;AAAA,IACA,YAAY;AAAA,IACZ,YAAY;AAAA,IACZ,YAAY;AAAA,IACZ;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC;AAED,SAAO,EAAE,MAAM,OAAO,QAAQ;AAChC;AAeO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA,GAAG;AACL,GAA6B;AAC3B,QAAM,EAAE,MAAM,MAAM,IAAI,iBAAiB,QAAQ,OAAO;AAExD,QAAM,OACJ,UAAU,WACN,WACA,UAAU,UACR,gBACA,UAAU,YACR,mBACA;AAEV,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,SAAS;AAAA,MACT,UAAU,UAAU;AAAA,MACpB,aAAU;AAAA,MACV,WACE,oKAEC,UAAU,UAAU,sBAAsB,OAC1C,aAAa;AAAA,MAGf;AAAA;AAAA,EACH;AAEJ;AAUO,SAAS,wBAAwB,OAAqC;AAC3E,SAAO,oBAAC,uBAAqB,GAAG,OAAO;AACzC;","names":[]}
1
+ {"version":3,"sources":["../src/short-link-react.tsx","../src/short-link.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/short-link-react — the drop-in short-link UI, headless included.\n *\n * The five-minute story: import the button, hand it your Supabase client, done.\n * The mint call (the org-gated `shorten_app_url` door), the URL shape, the\n * clipboard write, and the state feedback all live HERE — a consuming app\n * writes zero shortener logic.\n *\n * <CopyShortLinkButton client={supabase} path={`/notes/${id}`} organizationId={orgId} />\n * <CopyPageShortLinkButton client={supabase} organizationId={orgId} /> // THIS page, query string and all\n *\n * Custom chrome (a menu row, a context-menu item, an icon button)? Use the\n * headless hook and keep only the markup:\n *\n * const { copy, phase } = useCopyShortLink(supabase, { organizationId });\n * <button onClick={copy}>{phase === \"copied\" ? \"Copied\" : \"Copy short link\"}</button>\n *\n * Omitting `path` means \"the current page at the moment of the click\" —\n * pathname + query + hash, so a sorted/filtered/column-configured view\n * shortens to exactly that view.\n *\n * Runtime deps of this subpath: none beyond React.\n * Styling is the Matrx Tailwind semantic-token vocabulary; override via\n * `className` (the class string is appended).\n */\n\nimport { useCallback, useRef, useState } from \"react\";\n\nimport {\n currentAppPath,\n mintShortLink,\n type MintShortLinkOptions,\n type MintShortLinkResult,\n type ShortLinkClient,\n} from \"./short-link\";\n\nasync function writeClipboard(text: string): Promise<boolean> {\n try {\n await navigator.clipboard.writeText(text);\n return true;\n } catch {\n // Fall through — sandboxed webviews and permission-denied contexts land\n // here; the selection/execCommand path still works on a user gesture.\n }\n try {\n const scratch = document.createElement(\"textarea\");\n scratch.value = text;\n scratch.setAttribute(\"readonly\", \"\");\n scratch.style.position = \"fixed\";\n scratch.style.opacity = \"0\";\n document.body.appendChild(scratch);\n scratch.select();\n const copied = document.execCommand(\"copy\");\n scratch.remove();\n return copied;\n } catch {\n return false;\n }\n}\n\nexport interface UseShortLinkResult {\n /** Mint (once) and return the short URL; re-calls return the cached mint. */\n mint: () => Promise<MintShortLinkResult>;\n /** The minted URL, once `mint` has succeeded. */\n url: string | null;\n minting: boolean;\n error: string | null;\n}\n\n/** Mint-on-demand with caching — a fixed path known at render time. */\nexport function useShortLink(\n client: ShortLinkClient,\n options: MintShortLinkOptions,\n): UseShortLinkResult {\n const [url, setUrl] = useState<string | null>(null);\n const [minting, setMinting] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const cached = useRef<MintShortLinkResult | null>(null);\n\n const mint = useCallback(async (): Promise<MintShortLinkResult> => {\n if (cached.current?.ok) return cached.current;\n setMinting(true);\n setError(null);\n try {\n const result = await mintShortLink(client, options);\n cached.current = result;\n if (result.ok) {\n setUrl(result.url);\n } else {\n setError(result.error);\n }\n return result;\n } finally {\n setMinting(false);\n }\n }, [client, options.path, options.organizationId, options.expiresAt, options.origin]);\n\n return { mint, url, minting, error };\n}\n\nexport type CopyShortLinkPhase = \"idle\" | \"minting\" | \"copied\" | \"error\";\n\nexport interface UseCopyShortLinkOptions\n extends Omit<MintShortLinkOptions, \"path\"> {\n /**\n * Path to shorten. Omit for \"the current page at click time\" (pathname +\n * query + hash — the exact configured view).\n */\n path?: string;\n /** Called with the short URL after it lands on the clipboard. */\n onCopied?: (url: string) => void;\n onError?: (error: string) => void;\n}\n\nexport interface UseCopyShortLinkResult {\n /** Mint (cached per path) + copy to the clipboard. The whole flow. */\n copy: () => Promise<{ ok: boolean; url?: string; error?: string }>;\n phase: CopyShortLinkPhase;\n /** The last successfully copied URL. */\n lastUrl: string | null;\n}\n\n/**\n * The headless whole-flow hook: mint (cached per path), clipboard write, and a\n * self-resetting phase for feedback. Every piece of chrome — the buttons below,\n * a menu row, a context-menu item — sits on this.\n */\nexport function useCopyShortLink(\n client: ShortLinkClient,\n options: UseCopyShortLinkOptions,\n): UseCopyShortLinkResult {\n const [phase, setPhase] = useState<CopyShortLinkPhase>(\"idle\");\n const [lastUrl, setLastUrl] = useState<string | null>(null);\n const cache = useRef<Map<string, MintShortLinkResult>>(new Map());\n const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const { path, onCopied, onError, ...mintOptions } = options;\n\n const flash = useCallback((next: \"copied\" | \"error\") => {\n setPhase(next);\n if (resetTimer.current) clearTimeout(resetTimer.current);\n resetTimer.current = setTimeout(() => setPhase(\"idle\"), 2000);\n }, []);\n\n const copy = useCallback(async () => {\n const targetPath = path ?? currentAppPath();\n if (!targetPath) {\n flash(\"error\");\n onError?.(\"No path to shorten\");\n return { ok: false, error: \"No path to shorten\" };\n }\n let result = cache.current.get(targetPath);\n if (!result?.ok) {\n setPhase(\"minting\");\n result = await mintShortLink(client, { ...mintOptions, path: targetPath });\n cache.current.set(targetPath, result);\n }\n if (!result.ok) {\n flash(\"error\");\n onError?.(result.error);\n return { ok: false, error: result.error };\n }\n const copied = await writeClipboard(result.url);\n if (!copied) {\n flash(\"error\");\n onError?.(\"Could not write to the clipboard\");\n return { ok: false, error: \"Could not write to the clipboard\" };\n }\n setLastUrl(result.url);\n flash(\"copied\");\n onCopied?.(result.url);\n return { ok: true, url: result.url };\n }, [\n client,\n path,\n mintOptions.organizationId,\n mintOptions.expiresAt,\n mintOptions.origin,\n flash,\n onCopied,\n onError,\n ]);\n\n return { copy, phase, lastUrl };\n}\n\nexport interface CopyShortLinkButtonProps extends UseCopyShortLinkOptions {\n /** Your Supabase client (anything with a compatible `.rpc`). */\n client: ShortLinkClient;\n /** Button label; default \"Copy short link\". */\n label?: string;\n className?: string;\n}\n\n/**\n * One click: mint (first time only), copy the short URL, confirm inline.\n * The mint is lazy — no short-link row exists until someone actually asks\n * for the link. With no `path`, shortens the current page at click time.\n */\nexport function CopyShortLinkButton({\n client,\n label = \"Copy short link\",\n className,\n ...options\n}: CopyShortLinkButtonProps) {\n const { copy, phase } = useCopyShortLink(client, options);\n\n const text =\n phase === \"copied\"\n ? \"Copied\"\n : phase === \"error\"\n ? \"Copy failed\"\n : phase === \"minting\"\n ? \"Creating…\"\n : label;\n\n return (\n <button\n type=\"button\"\n onClick={copy}\n disabled={phase === \"minting\"}\n aria-live=\"polite\"\n className={\n \"inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 \" +\n \"text-sm font-medium text-foreground hover:bg-muted disabled:opacity-60 \" +\n (phase === \"error\" ? \"text-destructive \" : \"\") +\n (className ?? \"\")\n }\n >\n {text}\n </button>\n );\n}\n\nexport type CopyPageShortLinkButtonProps = Omit<CopyShortLinkButtonProps, \"path\">;\n\n/**\n * \"Copy a short link to THIS page\" — the zero-thought affordance. The path is\n * read at click time (pathname + query + hash), so whatever the person has\n * configured — sorts, filters, hidden columns, a hash target — is exactly what\n * the recipient opens.\n */\nexport function CopyPageShortLinkButton(props: CopyPageShortLinkButtonProps) {\n return <CopyShortLinkButton {...props} />;\n}\n","/**\n * @ai-matrx/kit/short-link — the platform short-link CONTRACT, in one place.\n *\n * The primitive itself lives in the database (migration `0557_platform_short_links`):\n * `platform.short_links` holds `token → same-app path`, org-scoped and expiring;\n * `platform.create_short_link` mints (server-side only); `public.resolve_short_link`\n * resolves (anon-callable; answers with a PATH, never content — the target route's\n * own auth gates everything). The frontend redirect route is `/r/[token]`\n * (matrx-frontend `app/(public)/r/[token]`), and the notification spine mints per\n * SMS leg (aidream `services/notifications/short_links.py`).\n *\n * This module is the shared vocabulary every client needs to speak about those\n * tokens — alphabet, length, validation, and the URL shape — so no consumer ever\n * re-declares it. 🚨 `SHORT_LINK_TOKEN_ALPHABET` and `SHORT_LINK_TOKEN_LENGTH`\n * mirror `platform.create_short_link` EXACTLY; change one and you change both.\n *\n * 🚨 A short link is a URL, not a permission. The primitive that grants\n * anonymous access to content is `platform.share_links` (`/s/[token]`, 64-hex\n * tokens) — a different system, deliberately. Never shorten by minting a share\n * link, and never share by minting a short link.\n *\n * Pure logic — no DOM, no React, no network; safe in Server Components, route\n * handlers, workers, and Node scripts.\n */\n\n/**\n * 32 characters: digits 2–9 plus a–z minus `l` and `o`. 256 % 32 = 0, so the\n * mint maps random bytes without modulo bias; lowercase-only survives channels\n * that case-mangle (the resolver lowercases before lookup); the ambiguous\n * glyphs (`0/o`, `1/l`) never appear.\n */\nexport const SHORT_LINK_TOKEN_ALPHABET = \"23456789abcdefghijkmnpqrstuvwxyz\";\n\n/** 10 characters × 5 bits = 50 bits — unguessable at any realistic probe rate. */\nexport const SHORT_LINK_TOKEN_LENGTH = 10;\n\n/** The resolve route on the app origin (matrx-frontend `app/(public)/r/[token]`). */\nexport const SHORT_LINK_PATH_PREFIX = \"/r/\";\n\nconst TOKEN_RE = new RegExp(`^[${SHORT_LINK_TOKEN_ALPHABET}]{${SHORT_LINK_TOKEN_LENGTH}}$`);\n\n/**\n * Trim, lowercase, and shape-check a candidate token. Returns the normalized\n * token, or `null` for anything outside the contract — the resolver would\n * refuse it anyway, so callers can skip the round trip.\n */\nexport function normalizeShortLinkToken(candidate: string | null | undefined): string | null {\n const token = (candidate ?? \"\").trim().toLowerCase();\n return TOKEN_RE.test(token) ? token : null;\n}\n\n/** Whether a string is a well-formed short-link token (after normalization). */\nexport function isShortLinkToken(candidate: string | null | undefined): boolean {\n return normalizeShortLinkToken(candidate) !== null;\n}\n\n/** The app-relative path a token resolves at: `/r/<token>`. */\nexport function shortLinkPath(token: string): string {\n const normalized = normalizeShortLinkToken(token);\n if (!normalized) {\n throw new Error(\n `shortLinkPath: ${JSON.stringify(token)} is not a short-link token ` +\n `(${SHORT_LINK_TOKEN_LENGTH} chars of \"${SHORT_LINK_TOKEN_ALPHABET}\")`,\n );\n }\n return `${SHORT_LINK_PATH_PREFIX}${normalized}`;\n}\n\n/**\n * The absolute short URL for a token on a given origin, e.g.\n * `shortLinkUrl(\"https://app.aimatrx.com\", token)` → `https://app.aimatrx.com/r/<token>`.\n */\nexport function shortLinkUrl(origin: string, token: string): string {\n return `${origin.replace(/\\/+$/, \"\")}${shortLinkPath(token)}`;\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// The client half — the WHOLE mint/resolve logic, so a consumer adds nothing.\n//\n// Pass your Supabase client (or anything with a compatible `.rpc`) and you are\n// done: `mintShortLink(supabase, { path, organizationId })` calls the platform's\n// authenticated mint door (`public.shorten_app_url`, org-membership-gated) and\n// hands back the finished short URL; `resolveShortLinkPath(supabase, token)`\n// calls the anon resolver (`public.resolve_short_link`). No app writes its own\n// shortener logic, ever — the only thing outside this module is the shared\n// database that stores the tokens, which is exactly where shared state lives.\n// ─────────────────────────────────────────────────────────────────────────────\n\n/** Anything with a Supabase-shaped `.rpc` — pass the Supabase client itself. */\nexport interface ShortLinkClient {\n rpc(\n fn: string,\n args?: Record<string, unknown>,\n ): PromiseLike<{ data: unknown; error: { message: string } | null }>;\n}\n\nexport interface MintShortLinkOptions {\n /** Same-app path to shorten (must start with a single `/`). */\n path: string;\n /** The organization the link belongs to — the caller must be a member. */\n organizationId: string;\n /** ISO timestamp; the platform default (365 days) applies when omitted. */\n expiresAt?: string;\n /** Origin for the returned URL; defaults to `window.location.origin`. */\n origin?: string;\n}\n\nexport type MintShortLinkResult =\n | { ok: true; token: string; path: string; url: string }\n | { ok: false; error: string };\n\nfunction resolveOrigin(origin?: string): string | null {\n if (origin) return origin;\n if (typeof window !== \"undefined\" && window.location?.origin) {\n return window.location.origin;\n }\n return null;\n}\n\n/** Mint a short link through the platform's authenticated mint door. */\nexport async function mintShortLink(\n client: ShortLinkClient,\n options: MintShortLinkOptions,\n): Promise<MintShortLinkResult> {\n if (!options.path.startsWith(\"/\") || options.path.startsWith(\"//\")) {\n return { ok: false, error: \"Only a same-app path (starting with a single '/') can be shortened\" };\n }\n const origin = resolveOrigin(options.origin);\n if (!origin) {\n return { ok: false, error: \"No origin: pass options.origin outside a browser\" };\n }\n const { data, error } = await client.rpc(\"shorten_app_url\", {\n p_path: options.path,\n p_organization_id: options.organizationId,\n ...(options.expiresAt ? { p_expires_at: options.expiresAt } : {}),\n });\n if (error) return { ok: false, error: error.message };\n const result = data as { ok?: boolean; token?: string; error?: string } | null;\n const token = result?.ok ? normalizeShortLinkToken(result.token) : null;\n if (!token) {\n return { ok: false, error: result?.error ?? \"The mint door returned no token\" };\n }\n return { ok: true, token, path: shortLinkPath(token), url: shortLinkUrl(origin, token) };\n}\n\nexport type ResolveShortLinkResult =\n | { ok: true; targetPath: string }\n | { ok: false; transportError?: string };\n\n/**\n * Resolve a short token to its target path through the anon resolver.\n * `{ok:false}` covers invalid, unknown, and expired identically — the platform\n * deliberately does not distinguish them. A TRANSPORT failure (the RPC itself\n * errored) additionally carries `transportError`: a gateway outage is not\n * \"this link is gone\", and a caller rendering a 404 must check for it first.\n */\nexport async function resolveShortLinkPath(\n client: ShortLinkClient,\n token: string,\n): Promise<ResolveShortLinkResult> {\n const normalized = normalizeShortLinkToken(token);\n if (!normalized) return { ok: false };\n const { data, error } = await client.rpc(\"resolve_short_link\", { p_token: normalized });\n if (error) return { ok: false, transportError: error.message };\n const result = data as { ok?: boolean; target_path?: string } | null;\n const targetPath = result?.ok ? result.target_path : undefined;\n if (!targetPath || !targetPath.startsWith(\"/\") || targetPath.startsWith(\"//\")) {\n return { ok: false };\n }\n return { ok: true, targetPath };\n}\n\n/**\n * The current page as a shortenable same-app path — pathname + query + hash,\n * exactly the \"this view, as I have it configured\" URL (sorts, filters, hidden\n * columns riding in the query string all survive). `null` outside a browser.\n */\nexport function currentAppPath(): string | null {\n if (typeof window === \"undefined\" || !window.location) return null;\n const { pathname, search, hash } = window.location;\n if (!pathname.startsWith(\"/\") || pathname.startsWith(\"//\")) return null;\n return `${pathname}${search}${hash}`;\n}\n"],"mappings":"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","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/toast.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/toast — the captured sonner wrapper, as a factory.\n *\n * A bare `toast.error(...)` from \"sonner\" is INVISIBLE to any error-capture\n * sink — every modern sonner call site silently bypasses error capture (the\n * exact hole matrx-frontend found in its marketing feature, 2026-07-20).\n * `createMatrxToast` returns a drop-in `toast` whose API is identical to the\n * one you pass in — `error` and `warning` additionally feed the injected\n * capture sink. A user seeing the failure is not evidence that it is minor,\n * so error toasts stay red unless a specific downgrade rule says otherwise.\n * Success/info/etc. pass straight through untouched.\n *\n * Ported from matrx-frontend `lib/toast.ts` with the two seams inverted:\n *\n * - `sonner` is NOT imported: the host passes its own `toast` object into the\n * factory (`createMatrxToast({ toast })`). That removes the peer-resolution\n * problem entirely — this module has zero dependencies — and the input is\n * typed STRUCTURALLY (callable + `.error` + `.warning`) so the returned\n * `toast` keeps the host's full sonner type, and no `.d.ts` in this package\n * references \"sonner\". The `sonner` entry in `peerDependencies` is\n * advisory-only (optional; nothing resolves it at runtime).\n * - `captureError` from the app's diagnostics store becomes the injected\n * `capture?: (info) => void`. Omitted, capture is a no-op and every call\n * forwards identically (the original's payload shape is preserved exactly:\n * `source: \"user-toast\"`, the `[warning] ` prefix, the \"Error toast\"\n * fallback, `raw: { kind, message?, data }`). A throwing `capture` never\n * breaks the toast.\n *\n * Usage (once, in the host app):\n * import { toast as sonnerToast } from \"sonner\";\n * export const { toast, toastErrorAlreadyCaptured } =\n * createMatrxToast({ toast: sonnerToast, capture: captureError });\n */\n\nimport type { ReactNode } from \"react\";\n\n/** What sonner accepts as a toast title (structural: sonner's `titleT`). */\nexport type ToastMessage = ReactNode | (() => ReactNode);\n\n/** Structural stand-in for sonner's `ExternalToast` options bag. */\nexport interface ToastData {\n description?: unknown;\n [key: string]: unknown;\n}\n\n/**\n * The structural surface this factory needs from the injected toast object:\n * callable, with `error` and `warning` methods. sonner's `toast` satisfies\n * this for every version this package targets. Deliberate typing choices so\n * the REAL sonner object is assignable without a cast under\n * `strictFunctionTypes`: the call signature uses `never[]` (accepts any\n * function — consumers call through their own `T`, never this signature),\n * and `error`/`warning` use METHOD syntax for bivariant parameter checks\n * against sonner's `ExternalToast`.\n */\nexport interface SonnerLikeToast {\n (...args: never[]): string | number;\n error(message: ToastMessage, data?: ToastData): string | number;\n warning(message: ToastMessage, data?: ToastData): string | number;\n}\n\n/** The payload handed to the injected capture sink — the original's shape. */\nexport interface CapturedToastInfo {\n source: \"user-toast\";\n message: string;\n userMessage: string;\n raw: {\n kind: \"error\" | \"warning\";\n message: string | undefined;\n data: ToastData | undefined;\n };\n}\n\nexport interface CreateMatrxToastOptions<T extends SonnerLikeToast> {\n /** The host's sonner `toast` object (or any structural equivalent). */\n toast: T;\n /**\n * Error-capture sink fed by `.error` / `.warning`. Omitted: no-op.\n * Must never be load-bearing — a throw here is swallowed.\n */\n capture?: (info: CapturedToastInfo) => void;\n}\n\nexport interface MatrxToast<T extends SonnerLikeToast> {\n /** Drop-in replacement for the injected `toast`, with error/warning capture. */\n toast: T;\n /**\n * Render an error toast when the originating failure was already captured\n * at its canonical boundary. Only for aggregate/derived UI notices; the\n * caller must be able to name the upstream capture seam.\n */\n toastErrorAlreadyCaptured: T[\"error\"];\n}\n\nfunction messageText(message: ToastMessage, data?: ToastData): string {\n const description =\n data && typeof data.description === \"string\" ? data.description : \"\";\n const title = typeof message === \"string\" ? message : \"\";\n return [title, description].filter(Boolean).join(\" — \") || \"Error toast\";\n}\n\n/** Build the captured toast pair around the host's sonner `toast` object. */\nexport function createMatrxToast<T extends SonnerLikeToast>({\n toast: hostToast,\n capture,\n}: CreateMatrxToastOptions<T>): MatrxToast<T> {\n if (typeof hostToast !== \"function\") {\n throw new Error(\n \"createMatrxToast: options.toast must be the sonner `toast` object (or a structural equivalent) — got \" +\n typeof hostToast,\n );\n }\n\n function captureToast(\n kind: \"error\" | \"warning\",\n message: ToastMessage,\n data?: ToastData,\n ): void {\n if (!capture) return;\n try {\n capture({\n source: \"user-toast\",\n message: `${kind === \"warning\" ? \"[warning] \" : \"\"}${messageText(message, data)}`,\n userMessage: messageText(message, data),\n raw: {\n kind,\n message: typeof message === \"string\" ? message : undefined,\n data,\n },\n });\n } catch {\n /* capture must never break the toast */\n }\n }\n\n const error: SonnerLikeToast[\"error\"] = (message, data) => {\n captureToast(\"error\", message, data);\n return hostToast.error(message, data);\n };\n\n const warning: SonnerLikeToast[\"warning\"] = (message, data) => {\n captureToast(\"warning\", message, data);\n return hostToast.warning(message, data);\n };\n\n // The constraint's call signature is `never[]` (see SonnerLikeToast), so\n // forwarding the base call goes through a widened alias of the host object.\n const forward = hostToast as unknown as (\n ...args: Parameters<T>\n ) => ReturnType<T>;\n const toast: T = Object.assign(\n ((...args: Parameters<T>) => forward(...args)) as unknown as T,\n hostToast,\n { error, warning },\n );\n\n const toastErrorAlreadyCaptured = ((\n message: ToastMessage,\n data?: ToastData,\n ) => hostToast.error(message, data)) as unknown as T[\"error\"];\n\n return { toast, toastErrorAlreadyCaptured };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AA8FA,SAAS,YAAY,SAAuB,MAA0B;AACpE,QAAM,cACJ,QAAQ,OAAO,KAAK,gBAAgB,WAAW,KAAK,cAAc;AACpE,QAAM,QAAQ,OAAO,YAAY,WAAW,UAAU;AACtD,SAAO,CAAC,OAAO,WAAW,EAAE,OAAO,OAAO,EAAE,KAAK,UAAK,KAAK;AAC7D;AAGO,SAAS,iBAA4C;AAAA,EAC1D,OAAO;AAAA,EACP;AACF,GAA8C;AAC5C,MAAI,OAAO,cAAc,YAAY;AACnC,UAAM,IAAI;AAAA,MACR,+GACE,OAAO;AAAA,IACX;AAAA,EACF;AAEA,WAAS,aACP,MACA,SACA,MACM;AACN,QAAI,CAAC,QAAS;AACd,QAAI;AACF,cAAQ;AAAA,QACN,QAAQ;AAAA,QACR,SAAS,GAAG,SAAS,YAAY,eAAe,EAAE,GAAG,YAAY,SAAS,IAAI,CAAC;AAAA,QAC/E,aAAa,YAAY,SAAS,IAAI;AAAA,QACtC,KAAK;AAAA,UACH;AAAA,UACA,SAAS,OAAO,YAAY,WAAW,UAAU;AAAA,UACjD;AAAA,QACF;AAAA,MACF,CAAC;AAAA,IACH,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,QAAkC,CAAC,SAAS,SAAS;AACzD,iBAAa,SAAS,SAAS,IAAI;AACnC,WAAO,UAAU,MAAM,SAAS,IAAI;AAAA,EACtC;AAEA,QAAM,UAAsC,CAAC,SAAS,SAAS;AAC7D,iBAAa,WAAW,SAAS,IAAI;AACrC,WAAO,UAAU,QAAQ,SAAS,IAAI;AAAA,EACxC;AAIA,QAAM,UAAU;AAGhB,QAAM,QAAW,OAAO;AAAA,KACrB,IAAI,SAAwB,QAAQ,GAAG,IAAI;AAAA,IAC5C;AAAA,IACA,EAAE,OAAO,QAAQ;AAAA,EACnB;AAEA,QAAM,6BAA6B,CACjC,SACA,SACG,UAAU,MAAM,SAAS,IAAI;AAElC,SAAO,EAAE,OAAO,0BAA0B;AAC5C;","names":[]}
1
+ {"version":3,"sources":["../src/toast.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/toast — the captured sonner wrapper, as a factory.\n *\n * A bare `toast.error(...)` from \"sonner\" is INVISIBLE to any error-capture\n * sink — every modern sonner call site silently bypasses error capture (the\n * exact hole matrx-frontend found in its marketing feature, 2026-07-20).\n * `createMatrxToast` returns a drop-in `toast` whose API is identical to the\n * one you pass in — `error` and `warning` additionally feed the injected\n * capture sink. A user seeing the failure is not evidence that it is minor,\n * so error toasts stay red unless a specific downgrade rule says otherwise.\n * Success/info/etc. pass straight through untouched.\n *\n * Ported from matrx-frontend `lib/toast.ts` with the two seams inverted:\n *\n * - `sonner` is NOT imported: the host passes its own `toast` object into the\n * factory (`createMatrxToast({ toast })`). That removes the peer-resolution\n * problem entirely — this module has zero dependencies — and the input is\n * typed STRUCTURALLY (callable + `.error` + `.warning`) so the returned\n * `toast` keeps the host's full sonner type, and no `.d.ts` in this package\n * references \"sonner\". Consequently `sonner` is NOT declared in the manifest\n * at all (X3: advisory peers are banned — nothing resolves it, so nothing\n * declares it; it remains a devDependency purely for the type-compat test).\n * - `captureError` from the app's diagnostics store becomes the injected\n * `capture?: (info) => void`. Omitted, capture is a no-op and every call\n * forwards identically (the original's payload shape is preserved exactly:\n * `source: \"user-toast\"`, the `[warning] ` prefix, the \"Error toast\"\n * fallback, `raw: { kind, message?, data }`). A throwing `capture` never\n * breaks the toast.\n *\n * Usage (once, in the host app):\n * import { toast as sonnerToast } from \"sonner\";\n * export const { toast, toastErrorAlreadyCaptured } =\n * createMatrxToast({ toast: sonnerToast, capture: captureError });\n */\n\nimport type { ReactNode } from \"react\";\n\n/** What sonner accepts as a toast title (structural: sonner's `titleT`). */\nexport type ToastMessage = ReactNode | (() => ReactNode);\n\n/** Structural stand-in for sonner's `ExternalToast` options bag. */\nexport interface ToastData {\n description?: unknown;\n [key: string]: unknown;\n}\n\n/**\n * The structural surface this factory needs from the injected toast object:\n * callable, with `error` and `warning` methods. sonner's `toast` satisfies\n * this for every version this package targets. Deliberate typing choices so\n * the REAL sonner object is assignable without a cast under\n * `strictFunctionTypes`: the call signature uses `never[]` (accepts any\n * function — consumers call through their own `T`, never this signature),\n * and `error`/`warning` use METHOD syntax for bivariant parameter checks\n * against sonner's `ExternalToast`.\n */\nexport interface SonnerLikeToast {\n (...args: never[]): string | number;\n error(message: ToastMessage, data?: ToastData): string | number;\n warning(message: ToastMessage, data?: ToastData): string | number;\n}\n\n/** The payload handed to the injected capture sink — the original's shape. */\nexport interface CapturedToastInfo {\n source: \"user-toast\";\n message: string;\n userMessage: string;\n raw: {\n kind: \"error\" | \"warning\";\n message: string | undefined;\n data: ToastData | undefined;\n };\n}\n\nexport interface CreateMatrxToastOptions<T extends SonnerLikeToast> {\n /** The host's sonner `toast` object (or any structural equivalent). */\n toast: T;\n /**\n * Error-capture sink fed by `.error` / `.warning`. Omitted: no-op.\n * Must never be load-bearing — a throw here is swallowed.\n */\n capture?: (info: CapturedToastInfo) => void;\n}\n\nexport interface MatrxToast<T extends SonnerLikeToast> {\n /** Drop-in replacement for the injected `toast`, with error/warning capture. */\n toast: T;\n /**\n * Render an error toast when the originating failure was already captured\n * at its canonical boundary. Only for aggregate/derived UI notices; the\n * caller must be able to name the upstream capture seam.\n */\n toastErrorAlreadyCaptured: T[\"error\"];\n}\n\nfunction messageText(message: ToastMessage, data?: ToastData): string {\n const description =\n data && typeof data.description === \"string\" ? data.description : \"\";\n const title = typeof message === \"string\" ? message : \"\";\n return [title, description].filter(Boolean).join(\" — \") || \"Error toast\";\n}\n\n/** Build the captured toast pair around the host's sonner `toast` object. */\nexport function createMatrxToast<T extends SonnerLikeToast>({\n toast: hostToast,\n capture,\n}: CreateMatrxToastOptions<T>): MatrxToast<T> {\n if (typeof hostToast !== \"function\") {\n throw new Error(\n \"createMatrxToast: options.toast must be the sonner `toast` object (or a structural equivalent) — got \" +\n typeof hostToast,\n );\n }\n\n function captureToast(\n kind: \"error\" | \"warning\",\n message: ToastMessage,\n data?: ToastData,\n ): void {\n if (!capture) return;\n try {\n capture({\n source: \"user-toast\",\n message: `${kind === \"warning\" ? \"[warning] \" : \"\"}${messageText(message, data)}`,\n userMessage: messageText(message, data),\n raw: {\n kind,\n message: typeof message === \"string\" ? message : undefined,\n data,\n },\n });\n } catch {\n /* capture must never break the toast */\n }\n }\n\n const error: SonnerLikeToast[\"error\"] = (message, data) => {\n captureToast(\"error\", message, data);\n return hostToast.error(message, data);\n };\n\n const warning: SonnerLikeToast[\"warning\"] = (message, data) => {\n captureToast(\"warning\", message, data);\n return hostToast.warning(message, data);\n };\n\n // The constraint's call signature is `never[]` (see SonnerLikeToast), so\n // forwarding the base call goes through a widened alias of the host object.\n const forward = hostToast as unknown as (\n ...args: Parameters<T>\n ) => ReturnType<T>;\n const toast: T = Object.assign(\n ((...args: Parameters<T>) => forward(...args)) as unknown as T,\n hostToast,\n { error, warning },\n );\n\n const toastErrorAlreadyCaptured = ((\n message: ToastMessage,\n data?: ToastData,\n ) => hostToast.error(message, data)) as unknown as T[\"error\"];\n\n return { toast, toastErrorAlreadyCaptured };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AA+FA,SAAS,YAAY,SAAuB,MAA0B;AACpE,QAAM,cACJ,QAAQ,OAAO,KAAK,gBAAgB,WAAW,KAAK,cAAc;AACpE,QAAM,QAAQ,OAAO,YAAY,WAAW,UAAU;AACtD,SAAO,CAAC,OAAO,WAAW,EAAE,OAAO,OAAO,EAAE,KAAK,UAAK,KAAK;AAC7D;AAGO,SAAS,iBAA4C;AAAA,EAC1D,OAAO;AAAA,EACP;AACF,GAA8C;AAC5C,MAAI,OAAO,cAAc,YAAY;AACnC,UAAM,IAAI;AAAA,MACR,+GACE,OAAO;AAAA,IACX;AAAA,EACF;AAEA,WAAS,aACP,MACA,SACA,MACM;AACN,QAAI,CAAC,QAAS;AACd,QAAI;AACF,cAAQ;AAAA,QACN,QAAQ;AAAA,QACR,SAAS,GAAG,SAAS,YAAY,eAAe,EAAE,GAAG,YAAY,SAAS,IAAI,CAAC;AAAA,QAC/E,aAAa,YAAY,SAAS,IAAI;AAAA,QACtC,KAAK;AAAA,UACH;AAAA,UACA,SAAS,OAAO,YAAY,WAAW,UAAU;AAAA,UACjD;AAAA,QACF;AAAA,MACF,CAAC;AAAA,IACH,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,QAAkC,CAAC,SAAS,SAAS;AACzD,iBAAa,SAAS,SAAS,IAAI;AACnC,WAAO,UAAU,MAAM,SAAS,IAAI;AAAA,EACtC;AAEA,QAAM,UAAsC,CAAC,SAAS,SAAS;AAC7D,iBAAa,WAAW,SAAS,IAAI;AACrC,WAAO,UAAU,QAAQ,SAAS,IAAI;AAAA,EACxC;AAIA,QAAM,UAAU;AAGhB,QAAM,QAAW,OAAO;AAAA,KACrB,IAAI,SAAwB,QAAQ,GAAG,IAAI;AAAA,IAC5C;AAAA,IACA,EAAE,OAAO,QAAQ;AAAA,EACnB;AAEA,QAAM,6BAA6B,CACjC,SACA,SACG,UAAU,MAAM,SAAS,IAAI;AAElC,SAAO,EAAE,OAAO,0BAA0B;AAC5C;","names":[]}
package/dist/toast.d.cts CHANGED
@@ -19,8 +19,9 @@ import { ReactNode } from 'react';
19
19
  * problem entirely — this module has zero dependencies — and the input is
20
20
  * typed STRUCTURALLY (callable + `.error` + `.warning`) so the returned
21
21
  * `toast` keeps the host's full sonner type, and no `.d.ts` in this package
22
- * references "sonner". The `sonner` entry in `peerDependencies` is
23
- * advisory-only (optional; nothing resolves it at runtime).
22
+ * references "sonner". Consequently `sonner` is NOT declared in the manifest
23
+ * at all (X3: advisory peers are banned — nothing resolves it, so nothing
24
+ * declares it; it remains a devDependency purely for the type-compat test).
24
25
  * - `captureError` from the app's diagnostics store becomes the injected
25
26
  * `capture?: (info) => void`. Omitted, capture is a no-op and every call
26
27
  * forwards identically (the original's payload shape is preserved exactly:
package/dist/toast.d.ts CHANGED
@@ -19,8 +19,9 @@ import { ReactNode } from 'react';
19
19
  * problem entirely — this module has zero dependencies — and the input is
20
20
  * typed STRUCTURALLY (callable + `.error` + `.warning`) so the returned
21
21
  * `toast` keeps the host's full sonner type, and no `.d.ts` in this package
22
- * references "sonner". The `sonner` entry in `peerDependencies` is
23
- * advisory-only (optional; nothing resolves it at runtime).
22
+ * references "sonner". Consequently `sonner` is NOT declared in the manifest
23
+ * at all (X3: advisory peers are banned — nothing resolves it, so nothing
24
+ * declares it; it remains a devDependency purely for the type-compat test).
24
25
  * - `captureError` from the app's diagnostics store becomes the injected
25
26
  * `capture?: (info) => void`. Omitted, capture is a no-op and every call
26
27
  * forwards identically (the original's payload shape is preserved exactly:
package/dist/toast.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/toast.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/toast — the captured sonner wrapper, as a factory.\n *\n * A bare `toast.error(...)` from \"sonner\" is INVISIBLE to any error-capture\n * sink — every modern sonner call site silently bypasses error capture (the\n * exact hole matrx-frontend found in its marketing feature, 2026-07-20).\n * `createMatrxToast` returns a drop-in `toast` whose API is identical to the\n * one you pass in — `error` and `warning` additionally feed the injected\n * capture sink. A user seeing the failure is not evidence that it is minor,\n * so error toasts stay red unless a specific downgrade rule says otherwise.\n * Success/info/etc. pass straight through untouched.\n *\n * Ported from matrx-frontend `lib/toast.ts` with the two seams inverted:\n *\n * - `sonner` is NOT imported: the host passes its own `toast` object into the\n * factory (`createMatrxToast({ toast })`). That removes the peer-resolution\n * problem entirely — this module has zero dependencies — and the input is\n * typed STRUCTURALLY (callable + `.error` + `.warning`) so the returned\n * `toast` keeps the host's full sonner type, and no `.d.ts` in this package\n * references \"sonner\". The `sonner` entry in `peerDependencies` is\n * advisory-only (optional; nothing resolves it at runtime).\n * - `captureError` from the app's diagnostics store becomes the injected\n * `capture?: (info) => void`. Omitted, capture is a no-op and every call\n * forwards identically (the original's payload shape is preserved exactly:\n * `source: \"user-toast\"`, the `[warning] ` prefix, the \"Error toast\"\n * fallback, `raw: { kind, message?, data }`). A throwing `capture` never\n * breaks the toast.\n *\n * Usage (once, in the host app):\n * import { toast as sonnerToast } from \"sonner\";\n * export const { toast, toastErrorAlreadyCaptured } =\n * createMatrxToast({ toast: sonnerToast, capture: captureError });\n */\n\nimport type { ReactNode } from \"react\";\n\n/** What sonner accepts as a toast title (structural: sonner's `titleT`). */\nexport type ToastMessage = ReactNode | (() => ReactNode);\n\n/** Structural stand-in for sonner's `ExternalToast` options bag. */\nexport interface ToastData {\n description?: unknown;\n [key: string]: unknown;\n}\n\n/**\n * The structural surface this factory needs from the injected toast object:\n * callable, with `error` and `warning` methods. sonner's `toast` satisfies\n * this for every version this package targets. Deliberate typing choices so\n * the REAL sonner object is assignable without a cast under\n * `strictFunctionTypes`: the call signature uses `never[]` (accepts any\n * function — consumers call through their own `T`, never this signature),\n * and `error`/`warning` use METHOD syntax for bivariant parameter checks\n * against sonner's `ExternalToast`.\n */\nexport interface SonnerLikeToast {\n (...args: never[]): string | number;\n error(message: ToastMessage, data?: ToastData): string | number;\n warning(message: ToastMessage, data?: ToastData): string | number;\n}\n\n/** The payload handed to the injected capture sink — the original's shape. */\nexport interface CapturedToastInfo {\n source: \"user-toast\";\n message: string;\n userMessage: string;\n raw: {\n kind: \"error\" | \"warning\";\n message: string | undefined;\n data: ToastData | undefined;\n };\n}\n\nexport interface CreateMatrxToastOptions<T extends SonnerLikeToast> {\n /** The host's sonner `toast` object (or any structural equivalent). */\n toast: T;\n /**\n * Error-capture sink fed by `.error` / `.warning`. Omitted: no-op.\n * Must never be load-bearing — a throw here is swallowed.\n */\n capture?: (info: CapturedToastInfo) => void;\n}\n\nexport interface MatrxToast<T extends SonnerLikeToast> {\n /** Drop-in replacement for the injected `toast`, with error/warning capture. */\n toast: T;\n /**\n * Render an error toast when the originating failure was already captured\n * at its canonical boundary. Only for aggregate/derived UI notices; the\n * caller must be able to name the upstream capture seam.\n */\n toastErrorAlreadyCaptured: T[\"error\"];\n}\n\nfunction messageText(message: ToastMessage, data?: ToastData): string {\n const description =\n data && typeof data.description === \"string\" ? data.description : \"\";\n const title = typeof message === \"string\" ? message : \"\";\n return [title, description].filter(Boolean).join(\" — \") || \"Error toast\";\n}\n\n/** Build the captured toast pair around the host's sonner `toast` object. */\nexport function createMatrxToast<T extends SonnerLikeToast>({\n toast: hostToast,\n capture,\n}: CreateMatrxToastOptions<T>): MatrxToast<T> {\n if (typeof hostToast !== \"function\") {\n throw new Error(\n \"createMatrxToast: options.toast must be the sonner `toast` object (or a structural equivalent) — got \" +\n typeof hostToast,\n );\n }\n\n function captureToast(\n kind: \"error\" | \"warning\",\n message: ToastMessage,\n data?: ToastData,\n ): void {\n if (!capture) return;\n try {\n capture({\n source: \"user-toast\",\n message: `${kind === \"warning\" ? \"[warning] \" : \"\"}${messageText(message, data)}`,\n userMessage: messageText(message, data),\n raw: {\n kind,\n message: typeof message === \"string\" ? message : undefined,\n data,\n },\n });\n } catch {\n /* capture must never break the toast */\n }\n }\n\n const error: SonnerLikeToast[\"error\"] = (message, data) => {\n captureToast(\"error\", message, data);\n return hostToast.error(message, data);\n };\n\n const warning: SonnerLikeToast[\"warning\"] = (message, data) => {\n captureToast(\"warning\", message, data);\n return hostToast.warning(message, data);\n };\n\n // The constraint's call signature is `never[]` (see SonnerLikeToast), so\n // forwarding the base call goes through a widened alias of the host object.\n const forward = hostToast as unknown as (\n ...args: Parameters<T>\n ) => ReturnType<T>;\n const toast: T = Object.assign(\n ((...args: Parameters<T>) => forward(...args)) as unknown as T,\n hostToast,\n { error, warning },\n );\n\n const toastErrorAlreadyCaptured = ((\n message: ToastMessage,\n data?: ToastData,\n ) => hostToast.error(message, data)) as unknown as T[\"error\"];\n\n return { toast, toastErrorAlreadyCaptured };\n}\n"],"mappings":";;;AA8FA,SAAS,YAAY,SAAuB,MAA0B;AACpE,QAAM,cACJ,QAAQ,OAAO,KAAK,gBAAgB,WAAW,KAAK,cAAc;AACpE,QAAM,QAAQ,OAAO,YAAY,WAAW,UAAU;AACtD,SAAO,CAAC,OAAO,WAAW,EAAE,OAAO,OAAO,EAAE,KAAK,UAAK,KAAK;AAC7D;AAGO,SAAS,iBAA4C;AAAA,EAC1D,OAAO;AAAA,EACP;AACF,GAA8C;AAC5C,MAAI,OAAO,cAAc,YAAY;AACnC,UAAM,IAAI;AAAA,MACR,+GACE,OAAO;AAAA,IACX;AAAA,EACF;AAEA,WAAS,aACP,MACA,SACA,MACM;AACN,QAAI,CAAC,QAAS;AACd,QAAI;AACF,cAAQ;AAAA,QACN,QAAQ;AAAA,QACR,SAAS,GAAG,SAAS,YAAY,eAAe,EAAE,GAAG,YAAY,SAAS,IAAI,CAAC;AAAA,QAC/E,aAAa,YAAY,SAAS,IAAI;AAAA,QACtC,KAAK;AAAA,UACH;AAAA,UACA,SAAS,OAAO,YAAY,WAAW,UAAU;AAAA,UACjD;AAAA,QACF;AAAA,MACF,CAAC;AAAA,IACH,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,QAAkC,CAAC,SAAS,SAAS;AACzD,iBAAa,SAAS,SAAS,IAAI;AACnC,WAAO,UAAU,MAAM,SAAS,IAAI;AAAA,EACtC;AAEA,QAAM,UAAsC,CAAC,SAAS,SAAS;AAC7D,iBAAa,WAAW,SAAS,IAAI;AACrC,WAAO,UAAU,QAAQ,SAAS,IAAI;AAAA,EACxC;AAIA,QAAM,UAAU;AAGhB,QAAM,QAAW,OAAO;AAAA,KACrB,IAAI,SAAwB,QAAQ,GAAG,IAAI;AAAA,IAC5C;AAAA,IACA,EAAE,OAAO,QAAQ;AAAA,EACnB;AAEA,QAAM,6BAA6B,CACjC,SACA,SACG,UAAU,MAAM,SAAS,IAAI;AAElC,SAAO,EAAE,OAAO,0BAA0B;AAC5C;","names":[]}
1
+ {"version":3,"sources":["../src/toast.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/toast — the captured sonner wrapper, as a factory.\n *\n * A bare `toast.error(...)` from \"sonner\" is INVISIBLE to any error-capture\n * sink — every modern sonner call site silently bypasses error capture (the\n * exact hole matrx-frontend found in its marketing feature, 2026-07-20).\n * `createMatrxToast` returns a drop-in `toast` whose API is identical to the\n * one you pass in — `error` and `warning` additionally feed the injected\n * capture sink. A user seeing the failure is not evidence that it is minor,\n * so error toasts stay red unless a specific downgrade rule says otherwise.\n * Success/info/etc. pass straight through untouched.\n *\n * Ported from matrx-frontend `lib/toast.ts` with the two seams inverted:\n *\n * - `sonner` is NOT imported: the host passes its own `toast` object into the\n * factory (`createMatrxToast({ toast })`). That removes the peer-resolution\n * problem entirely — this module has zero dependencies — and the input is\n * typed STRUCTURALLY (callable + `.error` + `.warning`) so the returned\n * `toast` keeps the host's full sonner type, and no `.d.ts` in this package\n * references \"sonner\". Consequently `sonner` is NOT declared in the manifest\n * at all (X3: advisory peers are banned — nothing resolves it, so nothing\n * declares it; it remains a devDependency purely for the type-compat test).\n * - `captureError` from the app's diagnostics store becomes the injected\n * `capture?: (info) => void`. Omitted, capture is a no-op and every call\n * forwards identically (the original's payload shape is preserved exactly:\n * `source: \"user-toast\"`, the `[warning] ` prefix, the \"Error toast\"\n * fallback, `raw: { kind, message?, data }`). A throwing `capture` never\n * breaks the toast.\n *\n * Usage (once, in the host app):\n * import { toast as sonnerToast } from \"sonner\";\n * export const { toast, toastErrorAlreadyCaptured } =\n * createMatrxToast({ toast: sonnerToast, capture: captureError });\n */\n\nimport type { ReactNode } from \"react\";\n\n/** What sonner accepts as a toast title (structural: sonner's `titleT`). */\nexport type ToastMessage = ReactNode | (() => ReactNode);\n\n/** Structural stand-in for sonner's `ExternalToast` options bag. */\nexport interface ToastData {\n description?: unknown;\n [key: string]: unknown;\n}\n\n/**\n * The structural surface this factory needs from the injected toast object:\n * callable, with `error` and `warning` methods. sonner's `toast` satisfies\n * this for every version this package targets. Deliberate typing choices so\n * the REAL sonner object is assignable without a cast under\n * `strictFunctionTypes`: the call signature uses `never[]` (accepts any\n * function — consumers call through their own `T`, never this signature),\n * and `error`/`warning` use METHOD syntax for bivariant parameter checks\n * against sonner's `ExternalToast`.\n */\nexport interface SonnerLikeToast {\n (...args: never[]): string | number;\n error(message: ToastMessage, data?: ToastData): string | number;\n warning(message: ToastMessage, data?: ToastData): string | number;\n}\n\n/** The payload handed to the injected capture sink — the original's shape. */\nexport interface CapturedToastInfo {\n source: \"user-toast\";\n message: string;\n userMessage: string;\n raw: {\n kind: \"error\" | \"warning\";\n message: string | undefined;\n data: ToastData | undefined;\n };\n}\n\nexport interface CreateMatrxToastOptions<T extends SonnerLikeToast> {\n /** The host's sonner `toast` object (or any structural equivalent). */\n toast: T;\n /**\n * Error-capture sink fed by `.error` / `.warning`. Omitted: no-op.\n * Must never be load-bearing — a throw here is swallowed.\n */\n capture?: (info: CapturedToastInfo) => void;\n}\n\nexport interface MatrxToast<T extends SonnerLikeToast> {\n /** Drop-in replacement for the injected `toast`, with error/warning capture. */\n toast: T;\n /**\n * Render an error toast when the originating failure was already captured\n * at its canonical boundary. Only for aggregate/derived UI notices; the\n * caller must be able to name the upstream capture seam.\n */\n toastErrorAlreadyCaptured: T[\"error\"];\n}\n\nfunction messageText(message: ToastMessage, data?: ToastData): string {\n const description =\n data && typeof data.description === \"string\" ? data.description : \"\";\n const title = typeof message === \"string\" ? message : \"\";\n return [title, description].filter(Boolean).join(\" — \") || \"Error toast\";\n}\n\n/** Build the captured toast pair around the host's sonner `toast` object. */\nexport function createMatrxToast<T extends SonnerLikeToast>({\n toast: hostToast,\n capture,\n}: CreateMatrxToastOptions<T>): MatrxToast<T> {\n if (typeof hostToast !== \"function\") {\n throw new Error(\n \"createMatrxToast: options.toast must be the sonner `toast` object (or a structural equivalent) — got \" +\n typeof hostToast,\n );\n }\n\n function captureToast(\n kind: \"error\" | \"warning\",\n message: ToastMessage,\n data?: ToastData,\n ): void {\n if (!capture) return;\n try {\n capture({\n source: \"user-toast\",\n message: `${kind === \"warning\" ? \"[warning] \" : \"\"}${messageText(message, data)}`,\n userMessage: messageText(message, data),\n raw: {\n kind,\n message: typeof message === \"string\" ? message : undefined,\n data,\n },\n });\n } catch {\n /* capture must never break the toast */\n }\n }\n\n const error: SonnerLikeToast[\"error\"] = (message, data) => {\n captureToast(\"error\", message, data);\n return hostToast.error(message, data);\n };\n\n const warning: SonnerLikeToast[\"warning\"] = (message, data) => {\n captureToast(\"warning\", message, data);\n return hostToast.warning(message, data);\n };\n\n // The constraint's call signature is `never[]` (see SonnerLikeToast), so\n // forwarding the base call goes through a widened alias of the host object.\n const forward = hostToast as unknown as (\n ...args: Parameters<T>\n ) => ReturnType<T>;\n const toast: T = Object.assign(\n ((...args: Parameters<T>) => forward(...args)) as unknown as T,\n hostToast,\n { error, warning },\n );\n\n const toastErrorAlreadyCaptured = ((\n message: ToastMessage,\n data?: ToastData,\n ) => hostToast.error(message, data)) as unknown as T[\"error\"];\n\n return { toast, toastErrorAlreadyCaptured };\n}\n"],"mappings":";;;AA+FA,SAAS,YAAY,SAAuB,MAA0B;AACpE,QAAM,cACJ,QAAQ,OAAO,KAAK,gBAAgB,WAAW,KAAK,cAAc;AACpE,QAAM,QAAQ,OAAO,YAAY,WAAW,UAAU;AACtD,SAAO,CAAC,OAAO,WAAW,EAAE,OAAO,OAAO,EAAE,KAAK,UAAK,KAAK;AAC7D;AAGO,SAAS,iBAA4C;AAAA,EAC1D,OAAO;AAAA,EACP;AACF,GAA8C;AAC5C,MAAI,OAAO,cAAc,YAAY;AACnC,UAAM,IAAI;AAAA,MACR,+GACE,OAAO;AAAA,IACX;AAAA,EACF;AAEA,WAAS,aACP,MACA,SACA,MACM;AACN,QAAI,CAAC,QAAS;AACd,QAAI;AACF,cAAQ;AAAA,QACN,QAAQ;AAAA,QACR,SAAS,GAAG,SAAS,YAAY,eAAe,EAAE,GAAG,YAAY,SAAS,IAAI,CAAC;AAAA,QAC/E,aAAa,YAAY,SAAS,IAAI;AAAA,QACtC,KAAK;AAAA,UACH;AAAA,UACA,SAAS,OAAO,YAAY,WAAW,UAAU;AAAA,UACjD;AAAA,QACF;AAAA,MACF,CAAC;AAAA,IACH,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,QAAkC,CAAC,SAAS,SAAS;AACzD,iBAAa,SAAS,SAAS,IAAI;AACnC,WAAO,UAAU,MAAM,SAAS,IAAI;AAAA,EACtC;AAEA,QAAM,UAAsC,CAAC,SAAS,SAAS;AAC7D,iBAAa,WAAW,SAAS,IAAI;AACrC,WAAO,UAAU,QAAQ,SAAS,IAAI;AAAA,EACxC;AAIA,QAAM,UAAU;AAGhB,QAAM,QAAW,OAAO;AAAA,KACrB,IAAI,SAAwB,QAAQ,GAAG,IAAI;AAAA,IAC5C;AAAA,IACA,EAAE,OAAO,QAAQ;AAAA,EACnB;AAEA,QAAM,6BAA6B,CACjC,SACA,SACG,UAAU,MAAM,SAAS,IAAI;AAElC,SAAO,EAAE,OAAO,0BAA0B;AAC5C;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/kit",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "The always-include AI Matrx kit: the little primitives every Matrx app speaks — autosave that never loses a keystroke, stale-response guards, clipboard with graceful fallbacks — one per subpath, tree-shaken to what you use.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -270,13 +270,7 @@
270
270
  "tailwind-merge": "^3.6.0"
271
271
  },
272
272
  "peerDependencies": {
273
- "react": ">=18.0.0",
274
- "sonner": ">=1.0.0"
275
- },
276
- "peerDependenciesMeta": {
277
- "sonner": {
278
- "optional": true
279
- }
273
+ "react": ">=18.0.0"
280
274
  },
281
275
  "devDependencies": {
282
276
  "@arethetypeswrong/cli": "^0.18.5",