This commit is contained in:
2025-12-17 13:33:37 -05:00
parent c49bfe5a3d
commit 3e3d9ed89b
2 changed files with 231 additions and 231 deletions

View File

@@ -443,226 +443,226 @@ function parseDiscordMarkdown(text, options = {}) {
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Code blocks (``` ```) - add data-lenis-prevent for independent scrolling
// Must be processed first to prevent other formatting inside code
// Don't trim - preserve whitespace for ASCII art
parsed = parsed.replace(/```(\w+)?\n?([\s\S]*?)```/g, (_, lang, code) => {
// Only trim trailing newline, preserve all other whitespace
const trimmedCode = code.replace(/\n$/, '');
return `<pre class="discord-code-block" data-lenis-prevent><code>${trimmedCode}</code></pre>`;
});
// Code blocks (``` ```) - add data-lenis-prevent for independent scrolling
// Must be processed first to prevent other formatting inside code
// Don't trim - preserve whitespace for ASCII art
parsed = parsed.replace(/```(\w+)?\n?([\s\S]*?)```/g, (_, lang, code) => {
// Only trim trailing newline, preserve all other whitespace
const trimmedCode = code.replace(/\n$/, '');
return `<pre class="discord-code-block" data-lenis-prevent><code>${trimmedCode}</code></pre>`;
});
// Inline code (`) - must be early to prevent formatting inside code
parsed = parsed.replace(/`([^`]+)`/g, '<code>$1</code>');
// Inline code (`) - must be early to prevent formatting inside code
parsed = parsed.replace(/`([^`]+)`/g, '<code>$1</code>');
// Blockquotes (> at start of line) - process before newline conversion
// Group consecutive > lines into a single blockquote
parsed = parsed.replace(/(^|\n)((?:&gt; .+(?:\n|$))+)/gm, (_, before, block) => {
const content = block
.split('\n')
.map(line => line.replace(/^&gt; /, ''))
.join('\n');
return `${before}<blockquote class="discord-blockquote" data-lenis-prevent>${content}</blockquote>`;
});
// Blockquotes (> at start of line) - process before newline conversion
// Group consecutive > lines into a single blockquote
parsed = parsed.replace(/(^|\n)((?:&gt; .+(?:\n|$))+)/gm, (_, before, block) => {
const content = block
.split('\n')
.map(line => line.replace(/^&gt; /, ''))
.join('\n');
return `${before}<blockquote class="discord-blockquote" data-lenis-prevent>${content}</blockquote>`;
});
// Headings (# ## ###) - must be at start of line
// Process before other inline formatting
parsed = parsed.replace(/(^|\n)### (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-heading discord-heading-3">${content}</span>`;
});
parsed = parsed.replace(/(^|\n)## (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-heading discord-heading-2">${content}</span>`;
});
parsed = parsed.replace(/(^|\n)# (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-heading discord-heading-1">${content}</span>`;
});
// Headings (# ## ###) - must be at start of line
// Process before other inline formatting
parsed = parsed.replace(/(^|\n)### (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-heading discord-heading-3">${content}</span>`;
});
parsed = parsed.replace(/(^|\n)## (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-heading discord-heading-2">${content}</span>`;
});
parsed = parsed.replace(/(^|\n)# (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-heading discord-heading-1">${content}</span>`;
});
// Subtext/small text (-# at start of line) - process before newline conversion
parsed = parsed.replace(/(^|\n)-# (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-subtext">${content}</span>`;
});
// Subtext/small text (-# at start of line) - process before newline conversion
parsed = parsed.replace(/(^|\n)-# (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-subtext">${content}</span>`;
});
// Unordered lists (- or * at start of line, but not ---)
parsed = parsed.replace(/(^|\n)[-*] (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-list-item">• ${content}</span>`;
});
// Unordered lists (- or * at start of line, but not ---)
parsed = parsed.replace(/(^|\n)[-*] (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-list-item">• ${content}</span>`;
});
// Ordered lists (1. 2. etc at start of line)
parsed = parsed.replace(/(^|\n)(\d+)\. (.+?)(?=\n|$)/gm, (_, before, num, content) => {
return `${before}<span class="discord-list-item">${num}. ${content}</span>`;
});
// Ordered lists (1. 2. etc at start of line)
parsed = parsed.replace(/(^|\n)(\d+)\. (.+?)(?=\n|$)/gm, (_, before, num, content) => {
return `${before}<span class="discord-list-item">${num}. ${content}</span>`;
});
// Bold + Italic + Underline combinations (most specific first)
// ___***text***___ or ***___text___***
parsed = parsed.replace(/(\*\*\*|___)(\*\*\*|___)([^*_]+)\2\1/g, '<strong><em><u>$3</u></em></strong>');
// Bold + Italic + Underline combinations (most specific first)
// ___***text***___ or ***___text___***
parsed = parsed.replace(/(\*\*\*|___)(\*\*\*|___)([^*_]+)\2\1/g, '<strong><em><u>$3</u></em></strong>');
// Bold + Italic (***text***)
parsed = parsed.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>');
// Bold + Italic (***text***)
parsed = parsed.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>');
// Bold + Underline (__**text**__ or **__text__**)
parsed = parsed.replace(/__\*\*([^*_]+)\*\*__/g, '<u><strong>$1</strong></u>');
parsed = parsed.replace(/\*\*__([^*_]+)__\*\*/g, '<strong><u>$1</u></strong>');
// Bold + Underline (__**text**__ or **__text__**)
parsed = parsed.replace(/__\*\*([^*_]+)\*\*__/g, '<u><strong>$1</strong></u>');
parsed = parsed.replace(/\*\*__([^*_]+)__\*\*/g, '<strong><u>$1</u></strong>');
// Italic + Underline (__*text*__ or *__text__* or ___text___)
parsed = parsed.replace(/__\*([^*_]+)\*__/g, '<u><em>$1</em></u>');
parsed = parsed.replace(/\*__([^*_]+)__\*/g, '<em><u>$1</u></em>');
parsed = parsed.replace(/___([^_]+)___/g, '<u><em>$1</em></u>');
// Italic + Underline (__*text*__ or *__text__* or ___text___)
parsed = parsed.replace(/__\*([^*_]+)\*__/g, '<u><em>$1</em></u>');
parsed = parsed.replace(/\*__([^*_]+)__\*/g, '<em><u>$1</u></em>');
parsed = parsed.replace(/___([^_]+)___/g, '<u><em>$1</em></u>');
// Bold (**)
parsed = parsed.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Bold (**)
parsed = parsed.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Underline (__) - must come before italic _ handling
parsed = parsed.replace(/__([^_]+)__/g, '<u>$1</u>');
// Underline (__) - must come before italic _ handling
parsed = parsed.replace(/__([^_]+)__/g, '<u>$1</u>');
// Italic (* or _)
parsed = parsed.replace(/\*([^*]+)\*/g, '<em>$1</em>');
parsed = parsed.replace(/\b_([^_]+)_\b/g, '<em>$1</em>');
// Italic (* or _)
parsed = parsed.replace(/\*([^*]+)\*/g, '<em>$1</em>');
parsed = parsed.replace(/\b_([^_]+)_\b/g, '<em>$1</em>');
// Strikethrough (~~)
parsed = parsed.replace(/~~([^~]+)~~/g, '<del>$1</del>');
// Strikethrough (~~)
parsed = parsed.replace(/~~([^~]+)~~/g, '<del>$1</del>');
// Spoiler (||)
parsed = parsed.replace(/\|\|([^|]+)\|\|/g, '<span class="discord-spoiler">$1</span>');
// Spoiler (||)
parsed = parsed.replace(/\|\|([^|]+)\|\|/g, '<span class="discord-spoiler">$1</span>');
// Discord Timestamps (<t:1234567890:F>)
parsed = parsed.replace(/&lt;t:(\d+)(?::([tTdDfFR]))?&gt;/g, (_, timestamp, format) => {
const date = new Date(parseInt(timestamp) * 1000);
let formatted;
switch (format) {
case 't': // Short time (9:30 PM)
formatted = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
break;
case 'T': // Long time (9:30:00 PM)
formatted = date.toLocaleTimeString();
break;
case 'd': // Short date (11/28/2024)
formatted = date.toLocaleDateString();
break;
case 'D': // Long date (November 28, 2024)
formatted = date.toLocaleDateString([], { dateStyle: 'long' });
break;
case 'f': // Short date/time (November 28, 2024 9:30 PM)
default:
formatted = date.toLocaleString([], { dateStyle: 'long', timeStyle: 'short' });
break;
case 'F': // Long date/time (Thursday, November 28, 2024 9:30 PM)
formatted = date.toLocaleString([], { dateStyle: 'full', timeStyle: 'short' });
break;
case 'R': // Relative (2 hours ago)
const now = Date.now();
const diff = now - date.getTime();
const seconds = Math.floor(Math.abs(diff) / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
// Discord Timestamps (<t:1234567890:F>)
parsed = parsed.replace(/&lt;t:(\d+)(?::([tTdDfFR]))?&gt;/g, (_, timestamp, format) => {
const date = new Date(parseInt(timestamp) * 1000);
let formatted;
switch (format) {
case 't': // Short time (9:30 PM)
formatted = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
break;
case 'T': // Long time (9:30:00 PM)
formatted = date.toLocaleTimeString();
break;
case 'd': // Short date (11/28/2024)
formatted = date.toLocaleDateString();
break;
case 'D': // Long date (November 28, 2024)
formatted = date.toLocaleDateString([], { dateStyle: 'long' });
break;
case 'f': // Short date/time (November 28, 2024 9:30 PM)
default:
formatted = date.toLocaleString([], { dateStyle: 'long', timeStyle: 'short' });
break;
case 'F': // Long date/time (Thursday, November 28, 2024 9:30 PM)
formatted = date.toLocaleString([], { dateStyle: 'full', timeStyle: 'short' });
break;
case 'R': // Relative (2 hours ago)
const now = Date.now();
const diff = now - date.getTime();
const seconds = Math.floor(Math.abs(diff) / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
if (diff < 0) {
// Future
if (years > 0) formatted = `in ${years} year${years > 1 ? 's' : ''}`;
else if (months > 0) formatted = `in ${months} month${months > 1 ? 's' : ''}`;
else if (days > 0) formatted = `in ${days} day${days > 1 ? 's' : ''}`;
else if (hours > 0) formatted = `in ${hours} hour${hours > 1 ? 's' : ''}`;
else if (minutes > 0) formatted = `in ${minutes} minute${minutes > 1 ? 's' : ''}`;
else formatted = `in ${seconds} second${seconds > 1 ? 's' : ''}`;
} else {
// Past
if (years > 0) formatted = `${years} year${years > 1 ? 's' : ''} ago`;
else if (months > 0) formatted = `${months} month${months > 1 ? 's' : ''} ago`;
else if (days > 0) formatted = `${days} day${days > 1 ? 's' : ''} ago`;
else if (hours > 0) formatted = `${hours} hour${hours > 1 ? 's' : ''} ago`;
else if (minutes > 0) formatted = `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
else formatted = `${seconds} second${seconds > 1 ? 's' : ''} ago`;
}
break;
}
return `<span class="discord-timestamp" title="${date.toLocaleString()}">${formatted}</span>`;
});
// User mentions (<@123456789>)
parsed = parsed.replace(/&lt;@!?(\d+)&gt;/g, (_, userId) => {
const user = usersMap[userId];
const displayName = user?.displayName || user?.username || 'User';
const colorStyle = user?.color ? ` style="color: ${user.color}"` : '';
return `<span class="discord-mention"${colorStyle}>@${displayName}</span>`;
});
// @everyone and @here mentions
parsed = parsed.replace(/@(everyone|here)/g, '<span class="discord-mention discord-mention-everyone">@$1</span>');
// Channel mentions (<#123456789>)
parsed = parsed.replace(/&lt;#(\d+)&gt;/g, (_, channelId) => {
const channel = channelMap.get(channelId);
const channelName = channel?.name || 'channel';
if (channel) {
return `<a href="#" class="discord-mention discord-channel-link" data-channel-id="${channelId}">#${channelName}</a>`;
}
return `<span class="discord-mention">#${channelName}</span>`;
});
// Role mentions (<@&123456789>) - robust lookup to avoid errors when rolesMap is missing or malformed
parsed = parsed.replace(/&lt;@&amp;(\d+)&gt;/g, (_, roleId) => {
try {
let role = null;
if (rolesMap) {
if (typeof rolesMap.get === 'function') {
role = rolesMap.get(roleId);
} else if (rolesMap[roleId]) {
role = rolesMap[roleId];
} else if (rolesMap[String(roleId)]) {
role = rolesMap[String(roleId)];
}
if (diff < 0) {
// Future
if (years > 0) formatted = `in ${years} year${years > 1 ? 's' : ''}`;
else if (months > 0) formatted = `in ${months} month${months > 1 ? 's' : ''}`;
else if (days > 0) formatted = `in ${days} day${days > 1 ? 's' : ''}`;
else if (hours > 0) formatted = `in ${hours} hour${hours > 1 ? 's' : ''}`;
else if (minutes > 0) formatted = `in ${minutes} minute${minutes > 1 ? 's' : ''}`;
else formatted = `in ${seconds} second${seconds > 1 ? 's' : ''}`;
} else {
// Past
if (years > 0) formatted = `${years} year${years > 1 ? 's' : ''} ago`;
else if (months > 0) formatted = `${months} month${months > 1 ? 's' : ''} ago`;
else if (days > 0) formatted = `${days} day${days > 1 ? 's' : ''} ago`;
else if (hours > 0) formatted = `${hours} hour${hours > 1 ? 's' : ''} ago`;
else if (minutes > 0) formatted = `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
else formatted = `${seconds} second${seconds > 1 ? 's' : ''} ago`;
}
break;
}
const roleName = role?.name || 'role';
const roleColor = role?.color || null;
const style = roleColor ? ` style="color: ${roleColor}; background-color: ${roleColor}20;"` : '';
return `<span class="discord-mention discord-role-mention"${style}>@${roleName}</span>`;
} catch (err) {
// Defensive: log for telemetry/debug and return safe fallback
try { console.error('parseDiscordMarkdown: role mention parse failed', { roleId, err, rolesMapType: rolesMap && typeof rolesMap }); } catch (e) { /* ignore logging errors */ }
return `<span class="discord-mention discord-role-mention">@role</span>`;
}
});
return `<span class="discord-timestamp" title="${date.toLocaleString()}">${formatted}</span>`;
});
// Slash command mentions (</command:123456789>)
parsed = parsed.replace(/&lt;\/([^:]+):(\d+)&gt;/g, '<span class="discord-slash-command">/$1</span>');
// User mentions (<@123456789>)
parsed = parsed.replace(/&lt;@!?(\d+)&gt;/g, (_, userId) => {
const user = usersMap[userId];
const displayName = user?.displayName || user?.username || 'User';
const colorStyle = user?.color ? ` style="color: ${user.color}"` : '';
return `<span class="discord-mention"${colorStyle}>@${displayName}</span>`;
});
// Custom emoji (<:name:123456789> or <a:name:123456789>)
// Use cached emoji URL if available, otherwise fall back to Discord CDN
parsed = parsed.replace(/&lt;(a)?:(\w+):(\d+)&gt;/g, (_, animated, name, id) => {
const cached = emojiCache[id];
const url = cached?.url || `https://cdn.discordapp.com/emojis/${id}.${animated ? 'gif' : 'png'}`;
return `<img class="discord-emoji" src="${url}" alt=":${name}:" title=":${name}:">`;
});
// @everyone and @here mentions
parsed = parsed.replace(/@(everyone|here)/g, '<span class="discord-mention discord-mention-everyone">@$1</span>');
// Unicode emoji (keep as-is, they render natively)
// Channel mentions (<#123456789>)
parsed = parsed.replace(/&lt;#(\d+)&gt;/g, (_, channelId) => {
const channel = channelMap.get(channelId);
const channelName = channel?.name || 'channel';
if (channel) {
return `<a href="#" class="discord-mention discord-channel-link" data-channel-id="${channelId}">#${channelName}</a>`;
}
return `<span class="discord-mention">#${channelName}</span>`;
});
// Masked/Markdown links [text](url) or [text](url "title") - process before bare URL detection
parsed = parsed.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)"]+)(?:\s+"([^"]+)")?\)/g,
(_, text, url, title) => {
const titleAttr = title ? ` title="${title}"` : '';
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="discord-link"${titleAttr}>${text}</a>`;
}
);
// Role mentions (<@&123456789>) - robust lookup to avoid errors when rolesMap is missing or malformed
parsed = parsed.replace(/&lt;@&amp;(\d+)&gt;/g, (_, roleId) => {
try {
let role = null;
if (rolesMap) {
if (typeof rolesMap.get === 'function') {
role = rolesMap.get(roleId);
} else if (rolesMap[roleId]) {
role = rolesMap[roleId];
} else if (rolesMap[String(roleId)]) {
role = rolesMap[String(roleId)];
}
}
const roleName = role?.name || 'role';
const roleColor = role?.color || null;
const style = roleColor ? ` style="color: ${roleColor}; background-color: ${roleColor}20;"` : '';
return `<span class="discord-mention discord-role-mention"${style}>@${roleName}</span>`;
} catch (err) {
// Defensive: log for telemetry/debug and return safe fallback
try { console.error('parseDiscordMarkdown: role mention parse failed', { roleId, err, rolesMapType: rolesMap && typeof rolesMap }); } catch (e) { /* ignore logging errors */ }
return `<span class="discord-mention discord-role-mention">@role</span>`;
}
});
// Links (URLs) - use negative lookbehind to skip URLs in HTML attributes (src=", href=")
// Match URLs that are NOT preceded by =" or ='
parsed = parsed.replace(
/(?<![="'])(?<![=]["'])(https?:\/\/[^\s<>"']+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="discord-link">$1</a>'
);
// Slash command mentions (</command:123456789>)
parsed = parsed.replace(/&lt;\/([^:]+):(\d+)&gt;/g, '<span class="discord-slash-command">/$1</span>');
// Newlines
parsed = parsed.replace(/\n/g, '<br>');
// Custom emoji (<:name:123456789> or <a:name:123456789>)
// Use cached emoji URL if available, otherwise fall back to Discord CDN
parsed = parsed.replace(/&lt;(a)?:(\w+):(\d+)&gt;/g, (_, animated, name, id) => {
const cached = emojiCache[id];
const url = cached?.url || `https://cdn.discordapp.com/emojis/${id}.${animated ? 'gif' : 'png'}`;
return `<img class="discord-emoji" src="${url}" alt=":${name}:" title=":${name}:">`;
});
// Unescape Discord markdown escape sequences (\\_ \\* \\~ \\` \\| \\\\)
// Must be done after all markdown processing
parsed = parsed.replace(/\\([_*~`|\\])/g, '$1');
// Unicode emoji (keep as-is, they render natively)
return parsed;
// Masked/Markdown links [text](url) or [text](url "title") - process before bare URL detection
parsed = parsed.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)"]+)(?:\s+"([^"]+)")?\)/g,
(_, text, url, title) => {
const titleAttr = title ? ` title="${title}"` : '';
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="discord-link"${titleAttr}>${text}</a>`;
}
);
// Links (URLs) - use negative lookbehind to skip URLs in HTML attributes (src=", href=")
// Match URLs that are NOT preceded by =" or ='
parsed = parsed.replace(
/(?<![="'])(?<![=]["'])(https?:\/\/[^\s<>"']+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="discord-link">$1</a>'
);
// Newlines
parsed = parsed.replace(/\n/g, '<br>');
// Unescape Discord markdown escape sequences (\\_ \\* \\~ \\` \\| \\\\)
// Must be done after all markdown processing
parsed = parsed.replace(/\\([_*~`|\\])/g, '$1');
return parsed;
} catch (err) {
try { console.error('parseDiscordMarkdown failed', err); } catch (e) { /* ignore logging errors */ }
// Fallback: return a safely-escaped version of the input to avoid crashing the UI

View File

@@ -19,46 +19,46 @@ const ReqForm = lazy(() => import('./req/ReqForm.jsx'));
// Inline minimal JoyUI wrapper to avoid importing Components.jsx
function MinimalJoyWrapper({ children }) {
const cache = React.useRef();
if (!cache.current) {
cache.current = createCache({ key: "joy-sub" });
}
return (
<CacheProvider value={cache.current}>
<CssVarsProvider>{children}</CssVarsProvider>
</CacheProvider>
);
const cache = React.useRef();
if (!cache.current) {
cache.current = createCache({ key: "joy-sub" });
}
return (
<CacheProvider value={cache.current}>
<CssVarsProvider>{children}</CssVarsProvider>
</CacheProvider>
);
}
export default function SubsiteRoot({ child, ...props }) {
if (typeof window !== 'undefined') {
window.toast = toast;
}
const theme = typeof document !== 'undefined'
? document.documentElement.getAttribute("data-theme")
: 'light';
if (typeof window !== 'undefined') {
window.toast = toast;
}
const theme = typeof document !== 'undefined'
? document.documentElement.getAttribute("data-theme")
: 'light';
return (
<PrimeReactProvider>
<ToastContainer
position="top-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop={true}
closeOnClick={true}
rtl={false}
pauseOnFocusLoss={false}
draggable
pauseOnHover
theme={theme === 'dark' ? 'dark' : 'light'}
/>
<MinimalJoyWrapper>
{child === "ReqForm" && (
<Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>}>
<ReqForm {...props} />
</Suspense>
)}
</MinimalJoyWrapper>
</PrimeReactProvider>
);
return (
<PrimeReactProvider>
<ToastContainer
position="top-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop={true}
closeOnClick={true}
rtl={false}
pauseOnFocusLoss={false}
draggable
pauseOnHover
theme={theme === 'dark' ? 'dark' : 'light'}
/>
<MinimalJoyWrapper>
{child === "ReqForm" && (
<Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>}>
<ReqForm {...props} />
</Suspense>
)}
</MinimalJoyWrapper>
</PrimeReactProvider>
);
}