@personaliai/react-widget 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/chunk-PR4QN5HX.js +43 -0
- package/dist/chunk-PR4QN5HX.js.map +1 -0
- package/dist/emoji-picker-react.esm-JCK7JWXK.js +26071 -0
- package/dist/emoji-picker-react.esm-JCK7JWXK.js.map +1 -0
- package/dist/index.cjs +98050 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +28 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +71952 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +259 -0
- package/package.json +57 -0
- package/src/ChatWidgetCore.tsx +1919 -0
- package/src/attach-menu.tsx +60 -0
- package/src/color-contrast.ts +186 -0
- package/src/index.ts +1 -0
- package/src/quick-emoji-picker.tsx +75 -0
- package/src/safe-markdown-link.tsx +44 -0
- package/src/voice-call-widget.tsx +564 -0
- package/src/widget-presets.css +259 -0
- package/src/widget-style.ts +88 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
import { Image as ImageIcon, FileText, MapPin } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
interface AttachMenuProps {
|
|
7
|
+
onPickImages: () => void;
|
|
8
|
+
onPickDocuments: () => void;
|
|
9
|
+
onShareLocation: () => void;
|
|
10
|
+
accentColor?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ITEMS = (accentColor: string) => [
|
|
14
|
+
{ key: "images", label: "Photos & videos", icon: ImageIcon, bg: `${accentColor}1a`, fg: accentColor },
|
|
15
|
+
{ key: "documents", label: "Documents", icon: FileText, bg: "#3b82f61a", fg: "#3b82f6" },
|
|
16
|
+
{ key: "location", label: "Location", icon: MapPin, bg: "#22c55e1a", fg: "#22c55e" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const listVariants = {
|
|
20
|
+
hidden: {},
|
|
21
|
+
show: { transition: { staggerChildren: 0.05, delayChildren: 0.04 } },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const itemVariants = {
|
|
25
|
+
hidden: { opacity: 0, y: 10, scale: 0.7 },
|
|
26
|
+
show: { opacity: 1, y: 0, scale: 1, transition: { type: "spring" as const, stiffness: 420, damping: 16 } },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function AttachMenu({ onPickImages, onPickDocuments, onShareLocation, accentColor = "#f97316" }: AttachMenuProps) {
|
|
30
|
+
const items = ITEMS(accentColor);
|
|
31
|
+
const handlers: Record<string, () => void> = {
|
|
32
|
+
images: onPickImages,
|
|
33
|
+
documents: onPickDocuments,
|
|
34
|
+
location: onShareLocation,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<motion.div variants={listVariants} initial="hidden" animate="show" className="flex flex-col gap-1 p-1.5">
|
|
39
|
+
{items.map((item) => {
|
|
40
|
+
const Icon = item.icon;
|
|
41
|
+
return (
|
|
42
|
+
<motion.button
|
|
43
|
+
key={item.key}
|
|
44
|
+
type="button"
|
|
45
|
+
variants={itemVariants}
|
|
46
|
+
whileHover={{ scale: 1.03, x: 2 }}
|
|
47
|
+
whileTap={{ scale: 0.95 }}
|
|
48
|
+
onClick={handlers[item.key]}
|
|
49
|
+
className="flex items-center gap-2.5 px-2.5 py-2 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-800 text-left"
|
|
50
|
+
>
|
|
51
|
+
<span className="size-8 rounded-full flex items-center justify-center shrink-0" style={{ background: item.bg, color: item.fg }}>
|
|
52
|
+
<Icon className="size-4" />
|
|
53
|
+
</span>
|
|
54
|
+
<span className="text-xs font-semibold">{item.label}</span>
|
|
55
|
+
</motion.button>
|
|
56
|
+
);
|
|
57
|
+
})}
|
|
58
|
+
</motion.div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WCAG-based "what text color goes on this background" helper. Used
|
|
3
|
+
* anywhere a widget preset paints an element's background with the
|
|
4
|
+
* business owner's arbitrary primaryColor (chat header, user bubble, the
|
|
5
|
+
* "Bold" preset) — a hardcoded text color there goes invisible the moment
|
|
6
|
+
* someone picks a color from the wrong half of the lightness spectrum.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function hexToRgb(hex: string): [number, number, number] {
|
|
10
|
+
const clean = hex.replace("#", "").trim();
|
|
11
|
+
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
|
12
|
+
const num = parseInt(full, 16);
|
|
13
|
+
if (full.length !== 6 || Number.isNaN(num)) return [249, 115, 22]; // fallback: brand orange
|
|
14
|
+
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function relativeLuminance(r: number, g: number, b: number): number {
|
|
18
|
+
const [rs, gs, bs] = [r, g, b].map((c) => {
|
|
19
|
+
const v = c / 255;
|
|
20
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
21
|
+
});
|
|
22
|
+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function contrastRatio(l1: number, l2: number): number {
|
|
26
|
+
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
|
|
27
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returns white / near-black text for a given background hex.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately NOT "whichever of white/black has the higher literal WCAG
|
|
34
|
+
* ratio" — that maximization picks black for nearly every saturated brand
|
|
35
|
+
* color (orange #f97316, green #10b981/#22c55e, blue #3b82f6, red #ef4444
|
|
36
|
+
* all land here: mid-lightness luminance means black's ratio against it
|
|
37
|
+
* edges out white's, even though white is the near-universal design-system
|
|
38
|
+
* choice for buttons/badges in these colors). Instead, favor white unless
|
|
39
|
+
* the background is light enough that white would actually wash out
|
|
40
|
+
* (pastels, near-white) — matching how colored UI chrome reads in practice.
|
|
41
|
+
*/
|
|
42
|
+
export function getOnColor(backgroundHex: string): "#ffffff" | "#111827" {
|
|
43
|
+
const [r, g, b] = hexToRgb(backgroundHex);
|
|
44
|
+
const bgLum = relativeLuminance(r, g, b);
|
|
45
|
+
return bgLum > 0.55 ? "#111827" : "#ffffff";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* --primary-color / --on-primary, the pair every widget preset
|
|
50
|
+
* (globals.css's .style-*) reads for its primaryColor-driven surfaces —
|
|
51
|
+
* one computation shared by every place that renders a preset (the real
|
|
52
|
+
* embedded widget, and the Customizer/Playground's non-iframe mock
|
|
53
|
+
* previews), so it can't drift between them.
|
|
54
|
+
*
|
|
55
|
+
* Surfaces that can't take the full saturated color without hurting
|
|
56
|
+
* legibility (a bot reply bubble, an input field) don't get a separate
|
|
57
|
+
* JS-precomputed tint — globals.css blends var(--primary-color) toward
|
|
58
|
+
* that surface's own curated color with CSS color-mix() instead, at a
|
|
59
|
+
* partial ratio. That keeps each preset's own light/dark character (a
|
|
60
|
+
* dark preset's bot-bubble tints toward primaryColor while staying dark;
|
|
61
|
+
* a light preset's stays light) without a fixed "tint toward white"
|
|
62
|
+
* assumption breaking already-dark designs like Dark Sleek.
|
|
63
|
+
*/
|
|
64
|
+
export function primaryColorCssVars(primaryColor: string): Record<string, string> {
|
|
65
|
+
return {
|
|
66
|
+
"--primary-color": primaryColor,
|
|
67
|
+
"--on-primary": getOnColor(primaryColor),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Per-section color scheme ────────────────────────────────────────────
|
|
72
|
+
// Section-by-section colors (header, bubbles, input bar, send button,
|
|
73
|
+
// launcher), each independently overridable in the Customizer, with a
|
|
74
|
+
// color-theory generator that fills in a full, harmonious set from one
|
|
75
|
+
// seed color — same hue throughout, only lightness/saturation shifted per
|
|
76
|
+
// surface, so "Auto-generate" never needs an actual model call.
|
|
77
|
+
|
|
78
|
+
function hexToHsl(hex: string): [number, number, number] {
|
|
79
|
+
const [r8, g8, b8] = hexToRgb(hex);
|
|
80
|
+
const r = r8 / 255, g = g8 / 255, b = b8 / 255;
|
|
81
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
82
|
+
const l = (max + min) / 2;
|
|
83
|
+
if (max === min) return [0, 0, l * 100];
|
|
84
|
+
const d = max - min;
|
|
85
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
86
|
+
let h: number;
|
|
87
|
+
switch (max) {
|
|
88
|
+
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
|
89
|
+
case g: h = ((b - r) / d + 2) / 6; break;
|
|
90
|
+
default: h = ((r - g) / d + 4) / 6;
|
|
91
|
+
}
|
|
92
|
+
return [h * 360, s * 100, l * 100];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hslToHex(h: number, s: number, l: number): string {
|
|
96
|
+
const S = s / 100, L = l / 100;
|
|
97
|
+
const c = (1 - Math.abs(2 * L - 1)) * S;
|
|
98
|
+
const hp = ((h % 360) + 360) % 360 / 60;
|
|
99
|
+
const x = c * (1 - Math.abs((hp % 2) - 1));
|
|
100
|
+
let [r1, g1, b1] = [0, 0, 0];
|
|
101
|
+
if (hp < 1) [r1, g1, b1] = [c, x, 0];
|
|
102
|
+
else if (hp < 2) [r1, g1, b1] = [x, c, 0];
|
|
103
|
+
else if (hp < 3) [r1, g1, b1] = [0, c, x];
|
|
104
|
+
else if (hp < 4) [r1, g1, b1] = [0, x, c];
|
|
105
|
+
else if (hp < 5) [r1, g1, b1] = [x, 0, c];
|
|
106
|
+
else [r1, g1, b1] = [c, 0, x];
|
|
107
|
+
const m = L - c / 2;
|
|
108
|
+
const toHex = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, "0");
|
|
109
|
+
return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A section's background + the text/icon color(s) it needs. */
|
|
113
|
+
export interface SectionColors {
|
|
114
|
+
bg: string;
|
|
115
|
+
text: string;
|
|
116
|
+
icon?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface WidgetColorScheme {
|
|
120
|
+
header: SectionColors;
|
|
121
|
+
botBubble: SectionColors;
|
|
122
|
+
userBubble: SectionColors;
|
|
123
|
+
inputBar: SectionColors;
|
|
124
|
+
sendBtn: SectionColors;
|
|
125
|
+
launcher: SectionColors;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Derives a full 6-section color scheme from one seed color — same hue
|
|
130
|
+
* throughout (color theory, not per-section arbitrary picks), lightness
|
|
131
|
+
* and saturation shifted per surface so bot-bubble/input-bar stay soft
|
|
132
|
+
* and legible instead of a jarring flat fill of the seed itself, and
|
|
133
|
+
* every text/icon color is computed (getOnColor) against its own actual
|
|
134
|
+
* background, never assumed.
|
|
135
|
+
*/
|
|
136
|
+
export function generateColorScheme(seedHex: string): WidgetColorScheme {
|
|
137
|
+
const [h, s] = hexToHsl(seedHex);
|
|
138
|
+
const solid = seedHex;
|
|
139
|
+
const solidText = getOnColor(solid);
|
|
140
|
+
// Soft, slightly desaturated tint of the same hue for message/input
|
|
141
|
+
// surfaces — light enough to read as neutral chrome, still visibly
|
|
142
|
+
// tinted toward the brand hue rather than generic gray.
|
|
143
|
+
const softBg = hslToHex(h, Math.min(s, 45) * 0.5, 95);
|
|
144
|
+
const softText = hslToHex(h, Math.min(s, 45) * 0.6, 22);
|
|
145
|
+
return {
|
|
146
|
+
header: { bg: solid, text: solidText },
|
|
147
|
+
botBubble: { bg: softBg, text: softText },
|
|
148
|
+
userBubble: { bg: solid, text: solidText },
|
|
149
|
+
inputBar: { bg: hslToHex(h, Math.min(s, 30) * 0.35, 97.5), text: softText, icon: hslToHex(h, Math.min(s, 50), 45) },
|
|
150
|
+
sendBtn: { bg: solid, text: solidText },
|
|
151
|
+
launcher: { bg: solid, text: solidText },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const HEX_RE = /^#[0-9a-fA-F]{3,8}$/;
|
|
156
|
+
function safeHex(h?: string): string | null {
|
|
157
|
+
return h && HEX_RE.test(h) ? h : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Builds the !important CSS override block for a color scheme, scoped
|
|
162
|
+
* under `scopeSelector` (an id or class on the widget's root element) so
|
|
163
|
+
* it reliably beats globals.css's .style-* !important rules regardless of
|
|
164
|
+
* which design preset is active. Shared by every place that renders a
|
|
165
|
+
* scheme — the real embedded widget (EmbedClient.tsx) and the dashboard's
|
|
166
|
+
* Customizer/Playground non-iframe mock previews — so they can't drift.
|
|
167
|
+
* Launcher isn't included: it lives outside this DOM tree entirely
|
|
168
|
+
* (widget.js/page.tsx own that separately).
|
|
169
|
+
*/
|
|
170
|
+
export function buildColorSchemeCss(scheme: WidgetColorScheme | null, scopeSelector: string): string {
|
|
171
|
+
if (!scheme) return "";
|
|
172
|
+
const rules: string[] = [];
|
|
173
|
+
const header = scheme.header, bg = safeHex(header?.bg), text = safeHex(header?.text);
|
|
174
|
+
if (bg && text) rules.push(`${scopeSelector} .chat-header { background: ${bg} !important; color: ${text} !important; }`);
|
|
175
|
+
const bot = scheme.botBubble, botBg = safeHex(bot?.bg), botText = safeHex(bot?.text);
|
|
176
|
+
if (botBg && botText) rules.push(`${scopeSelector} .bot-bubble { background-color: ${botBg} !important; color: ${botText} !important; }`);
|
|
177
|
+
const user = scheme.userBubble, userBg = safeHex(user?.bg), userText = safeHex(user?.text);
|
|
178
|
+
if (userBg && userText) rules.push(`${scopeSelector} .user-bubble { background-color: ${userBg} !important; color: ${userText} !important; }`);
|
|
179
|
+
const input = scheme.inputBar, inputBg = safeHex(input?.bg), inputText = safeHex(input?.text);
|
|
180
|
+
if (inputBg && inputText) rules.push(`${scopeSelector} .chat-input-bar { background-color: ${inputBg} !important; color: ${inputText} !important; }`);
|
|
181
|
+
const inputIcon = safeHex(input?.icon);
|
|
182
|
+
if (inputIcon) rules.push(`${scopeSelector} .chat-input-bar-icon { color: ${inputIcon} !important; }`);
|
|
183
|
+
const send = scheme.sendBtn, sendBg = safeHex(send?.bg), sendText = safeHex(send?.text);
|
|
184
|
+
if (sendBg && sendText) rules.push(`${scopeSelector} .send-btn { background-color: ${sendBg} !important; color: ${sendText} !important; }`);
|
|
185
|
+
return rules.join("\n");
|
|
186
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ChattyWidget, type ChatWidgetCoreProps as ChattyWidgetProps } from "./ChatWidgetCore";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Suspense, lazy, useEffect, useState } from "react";
|
|
4
|
+
import { Loader2 } from "lucide-react";
|
|
5
|
+
// Types/enums only (erased at compile time) — must NOT import any runtime
|
|
6
|
+
// value from "emoji-picker-react" here, or its whole (large) module gets
|
|
7
|
+
// pulled into the eagerly-loaded parts of the bundle, defeating the point
|
|
8
|
+
// of dynamically importing the actual <EmojiPicker> component below.
|
|
9
|
+
import type { EmojiClickData, Theme, SuggestionMode, EmojiStyle } from "emoji-picker-react";
|
|
10
|
+
|
|
11
|
+
// Code-split: emoji-picker-react's full Unicode dataset has no business
|
|
12
|
+
// being in the main bundle for a component that opens on a click. Its own
|
|
13
|
+
// `lazyLoadEmojis` further defers per-category emoji images until scrolled
|
|
14
|
+
// into view — this is what "efficient, fast opening" actually looks like
|
|
15
|
+
// for a *complete* emoji set (as opposed to the old hand-picked ~100-emoji
|
|
16
|
+
// list this file used to ship, which only felt fast because it was tiny).
|
|
17
|
+
//
|
|
18
|
+
// emojiStyle below is set to native rather than the library's APPLE
|
|
19
|
+
// default: the image styles (apple/twitter/facebook/google) render every
|
|
20
|
+
// emoji as its own separate PNG fetched from a jsdelivr CDN — dozens of
|
|
21
|
+
// individual network requests just to fill one category's visible grid,
|
|
22
|
+
// which is what actually showed up as a slow, empty-looking picker.
|
|
23
|
+
// Native emoji use the browser/OS's own emoji font: zero network requests,
|
|
24
|
+
// so the grid paints as fast as any other text on the page.
|
|
25
|
+
//
|
|
26
|
+
// Plain React.lazy (not next/dynamic): this component is also bundled
|
|
27
|
+
// standalone via Vite for the public widget (see widget-entry.tsx), which
|
|
28
|
+
// has no next/dynamic. next/dynamic's ssr:false behavior isn't needed here
|
|
29
|
+
// either way — the picker only ever mounts after emojiOpen flips true from
|
|
30
|
+
// a click, well after hydration, so there's nothing for SSR to render.
|
|
31
|
+
const EmojiPicker = lazy(() => import("emoji-picker-react"));
|
|
32
|
+
const EmojiPickerFallback = () => (
|
|
33
|
+
<div className="flex items-center justify-center h-full">
|
|
34
|
+
<Loader2 className="size-5 animate-spin text-neutral-300" />
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
interface QuickEmojiPickerProps {
|
|
39
|
+
onSelect: (emoji: string) => void;
|
|
40
|
+
accentColor?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function QuickEmojiPicker({ onSelect, accentColor = "#f97316" }: QuickEmojiPickerProps) {
|
|
44
|
+
// Matches the picker to the page's actual light/dark state (not just a
|
|
45
|
+
// media query) — same "dark" class toggling the rest of the dashboard uses.
|
|
46
|
+
const [isDark, setIsDark] = useState(false);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const root = document.documentElement;
|
|
49
|
+
const update = () => setIsDark(root.classList.contains("dark"));
|
|
50
|
+
update();
|
|
51
|
+
const observer = new MutationObserver(update);
|
|
52
|
+
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
|
|
53
|
+
return () => observer.disconnect();
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="flex flex-col h-full [&_.epr-main]:border-0 [&_.epr-main]:!h-full" style={{ ["--epr-highlight-color" as string]: accentColor }}>
|
|
58
|
+
<Suspense fallback={<EmojiPickerFallback />}>
|
|
59
|
+
<EmojiPicker
|
|
60
|
+
onEmojiClick={(data: EmojiClickData) => onSelect(data.emoji)}
|
|
61
|
+
theme={(isDark ? "dark" : "light") as Theme}
|
|
62
|
+
emojiStyle={"native" as EmojiStyle}
|
|
63
|
+
lazyLoadEmojis
|
|
64
|
+
autoFocusSearch
|
|
65
|
+
suggestedEmojisMode={"frequent" as SuggestionMode}
|
|
66
|
+
previewConfig={{ showPreview: false }}
|
|
67
|
+
skinTonesDisabled={false}
|
|
68
|
+
width="100%"
|
|
69
|
+
height="100%"
|
|
70
|
+
searchPlaceHolder="Search emoji…"
|
|
71
|
+
/>
|
|
72
|
+
</Suspense>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* react-markdown (v6+) does not sanitize link URI schemes on its own — a
|
|
5
|
+
* `[click me](javascript:...)` link in assistant/bot output (LLM-generated,
|
|
6
|
+
* or sourced from a crawled page / uploaded document) would otherwise
|
|
7
|
+
* render as a clickable `javascript:` href. Only allow schemes that can't
|
|
8
|
+
* execute script when clicked.
|
|
9
|
+
*/
|
|
10
|
+
const SAFE_SCHEMES = ["http:", "https:", "mailto:", "tel:"];
|
|
11
|
+
|
|
12
|
+
export function isSafeHref(href: string | undefined): boolean {
|
|
13
|
+
if (!href) return false;
|
|
14
|
+
const trimmed = href.trim();
|
|
15
|
+
// Relative/same-origin paths ("/foo", "#section", "?q=1") have no scheme
|
|
16
|
+
// to abuse and are always safe.
|
|
17
|
+
if (/^(\/|#|\?)/.test(trimmed)) return true;
|
|
18
|
+
try {
|
|
19
|
+
return SAFE_SCHEMES.includes(new URL(trimmed, "https://placeholder.invalid").protocol);
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Drop-in `a` component for ReactMarkdown's `components` prop. Renders
|
|
26
|
+
* unsafe-scheme links as inert text instead of a clickable anchor. */
|
|
27
|
+
export function SafeMarkdownLink({
|
|
28
|
+
href,
|
|
29
|
+
children,
|
|
30
|
+
className,
|
|
31
|
+
style,
|
|
32
|
+
}: {
|
|
33
|
+
href?: string;
|
|
34
|
+
children?: ReactNode;
|
|
35
|
+
className?: string;
|
|
36
|
+
style?: CSSProperties;
|
|
37
|
+
}) {
|
|
38
|
+
if (!isSafeHref(href)) return <span className={className}>{children}</span>;
|
|
39
|
+
return (
|
|
40
|
+
<a href={href} target="_blank" rel="noopener noreferrer" className={className} style={style}>
|
|
41
|
+
{children}
|
|
42
|
+
</a>
|
|
43
|
+
);
|
|
44
|
+
}
|