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, '&lt;')
.replace(/>/g, '&gt;'); .replace(/>/g, '&gt;');
// Code blocks (``` ```) - add data-lenis-prevent for independent scrolling // Code blocks (``` ```) - add data-lenis-prevent for independent scrolling
// Must be processed first to prevent other formatting inside code // Must be processed first to prevent other formatting inside code
// Don't trim - preserve whitespace for ASCII art // Don't trim - preserve whitespace for ASCII art
parsed = parsed.replace(/```(\w+)?\n?([\s\S]*?)```/g, (_, lang, code) => { parsed = parsed.replace(/```(\w+)?\n?([\s\S]*?)```/g, (_, lang, code) => {
// Only trim trailing newline, preserve all other whitespace // Only trim trailing newline, preserve all other whitespace
const trimmedCode = code.replace(/\n$/, ''); const trimmedCode = code.replace(/\n$/, '');
return `<pre class="discord-code-block" data-lenis-prevent><code>${trimmedCode}</code></pre>`; return `<pre class="discord-code-block" data-lenis-prevent><code>${trimmedCode}</code></pre>`;
}); });
// Inline code (`) - must be early to prevent formatting inside code // Inline code (`) - must be early to prevent formatting inside code
parsed = parsed.replace(/`([^`]+)`/g, '<code>$1</code>'); parsed = parsed.replace(/`([^`]+)`/g, '<code>$1</code>');
// Blockquotes (> at start of line) - process before newline conversion // Blockquotes (> at start of line) - process before newline conversion
// Group consecutive > lines into a single blockquote // Group consecutive > lines into a single blockquote
parsed = parsed.replace(/(^|\n)((?:&gt; .+(?:\n|$))+)/gm, (_, before, block) => { parsed = parsed.replace(/(^|\n)((?:&gt; .+(?:\n|$))+)/gm, (_, before, block) => {
const content = block const content = block
.split('\n') .split('\n')
.map(line => line.replace(/^&gt; /, '')) .map(line => line.replace(/^&gt; /, ''))
.join('\n'); .join('\n');
return `${before}<blockquote class="discord-blockquote" data-lenis-prevent>${content}</blockquote>`; return `${before}<blockquote class="discord-blockquote" data-lenis-prevent>${content}</blockquote>`;
}); });
// Headings (# ## ###) - must be at start of line // Headings (# ## ###) - must be at start of line
// Process before other inline formatting // Process before other inline formatting
parsed = parsed.replace(/(^|\n)### (.+?)(?=\n|$)/gm, (_, before, content) => { parsed = parsed.replace(/(^|\n)### (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-heading discord-heading-3">${content}</span>`; return `${before}<span class="discord-heading discord-heading-3">${content}</span>`;
}); });
parsed = parsed.replace(/(^|\n)## (.+?)(?=\n|$)/gm, (_, before, content) => { parsed = parsed.replace(/(^|\n)## (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-heading discord-heading-2">${content}</span>`; return `${before}<span class="discord-heading discord-heading-2">${content}</span>`;
}); });
parsed = parsed.replace(/(^|\n)# (.+?)(?=\n|$)/gm, (_, before, content) => { parsed = parsed.replace(/(^|\n)# (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-heading discord-heading-1">${content}</span>`; return `${before}<span class="discord-heading discord-heading-1">${content}</span>`;
}); });
// Subtext/small text (-# at start of line) - process before newline conversion // Subtext/small text (-# at start of line) - process before newline conversion
parsed = parsed.replace(/(^|\n)-# (.+?)(?=\n|$)/gm, (_, before, content) => { parsed = parsed.replace(/(^|\n)-# (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-subtext">${content}</span>`; return `${before}<span class="discord-subtext">${content}</span>`;
}); });
// Unordered lists (- or * at start of line, but not ---) // Unordered lists (- or * at start of line, but not ---)
parsed = parsed.replace(/(^|\n)[-*] (.+?)(?=\n|$)/gm, (_, before, content) => { parsed = parsed.replace(/(^|\n)[-*] (.+?)(?=\n|$)/gm, (_, before, content) => {
return `${before}<span class="discord-list-item">• ${content}</span>`; return `${before}<span class="discord-list-item">• ${content}</span>`;
}); });
// Ordered lists (1. 2. etc at start of line) // Ordered lists (1. 2. etc at start of line)
parsed = parsed.replace(/(^|\n)(\d+)\. (.+?)(?=\n|$)/gm, (_, before, num, content) => { parsed = parsed.replace(/(^|\n)(\d+)\. (.+?)(?=\n|$)/gm, (_, before, num, content) => {
return `${before}<span class="discord-list-item">${num}. ${content}</span>`; return `${before}<span class="discord-list-item">${num}. ${content}</span>`;
}); });
// Bold + Italic + Underline combinations (most specific first) // Bold + Italic + Underline combinations (most specific first)
// ___***text***___ or ***___text___*** // ___***text***___ or ***___text___***
parsed = parsed.replace(/(\*\*\*|___)(\*\*\*|___)([^*_]+)\2\1/g, '<strong><em><u>$3</u></em></strong>'); parsed = parsed.replace(/(\*\*\*|___)(\*\*\*|___)([^*_]+)\2\1/g, '<strong><em><u>$3</u></em></strong>');
// Bold + Italic (***text***) // Bold + Italic (***text***)
parsed = parsed.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>'); parsed = parsed.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>');
// Bold + Underline (__**text**__ or **__text__**) // Bold + Underline (__**text**__ or **__text__**)
parsed = parsed.replace(/__\*\*([^*_]+)\*\*__/g, '<u><strong>$1</strong></u>'); parsed = parsed.replace(/__\*\*([^*_]+)\*\*__/g, '<u><strong>$1</strong></u>');
parsed = parsed.replace(/\*\*__([^*_]+)__\*\*/g, '<strong><u>$1</u></strong>'); parsed = parsed.replace(/\*\*__([^*_]+)__\*\*/g, '<strong><u>$1</u></strong>');
// Italic + Underline (__*text*__ or *__text__* or ___text___) // Italic + Underline (__*text*__ or *__text__* or ___text___)
parsed = parsed.replace(/__\*([^*_]+)\*__/g, '<u><em>$1</em></u>'); parsed = parsed.replace(/__\*([^*_]+)\*__/g, '<u><em>$1</em></u>');
parsed = parsed.replace(/\*__([^*_]+)__\*/g, '<em><u>$1</u></em>'); parsed = parsed.replace(/\*__([^*_]+)__\*/g, '<em><u>$1</u></em>');
parsed = parsed.replace(/___([^_]+)___/g, '<u><em>$1</em></u>'); parsed = parsed.replace(/___([^_]+)___/g, '<u><em>$1</em></u>');
// Bold (**) // Bold (**)
parsed = parsed.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); parsed = parsed.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Underline (__) - must come before italic _ handling // Underline (__) - must come before italic _ handling
parsed = parsed.replace(/__([^_]+)__/g, '<u>$1</u>'); parsed = parsed.replace(/__([^_]+)__/g, '<u>$1</u>');
// Italic (* or _) // Italic (* or _)
parsed = parsed.replace(/\*([^*]+)\*/g, '<em>$1</em>'); parsed = parsed.replace(/\*([^*]+)\*/g, '<em>$1</em>');
parsed = parsed.replace(/\b_([^_]+)_\b/g, '<em>$1</em>'); parsed = parsed.replace(/\b_([^_]+)_\b/g, '<em>$1</em>');
// Strikethrough (~~) // Strikethrough (~~)
parsed = parsed.replace(/~~([^~]+)~~/g, '<del>$1</del>'); parsed = parsed.replace(/~~([^~]+)~~/g, '<del>$1</del>');
// Spoiler (||) // Spoiler (||)
parsed = parsed.replace(/\|\|([^|]+)\|\|/g, '<span class="discord-spoiler">$1</span>'); parsed = parsed.replace(/\|\|([^|]+)\|\|/g, '<span class="discord-spoiler">$1</span>');
// Discord Timestamps (<t:1234567890:F>) // Discord Timestamps (<t:1234567890:F>)
parsed = parsed.replace(/&lt;t:(\d+)(?::([tTdDfFR]))?&gt;/g, (_, timestamp, format) => { parsed = parsed.replace(/&lt;t:(\d+)(?::([tTdDfFR]))?&gt;/g, (_, timestamp, format) => {
const date = new Date(parseInt(timestamp) * 1000); const date = new Date(parseInt(timestamp) * 1000);
let formatted; let formatted;
switch (format) { switch (format) {
case 't': // Short time (9:30 PM) case 't': // Short time (9:30 PM)
formatted = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); formatted = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
break; break;
case 'T': // Long time (9:30:00 PM) case 'T': // Long time (9:30:00 PM)
formatted = date.toLocaleTimeString(); formatted = date.toLocaleTimeString();
break; break;
case 'd': // Short date (11/28/2024) case 'd': // Short date (11/28/2024)
formatted = date.toLocaleDateString(); formatted = date.toLocaleDateString();
break; break;
case 'D': // Long date (November 28, 2024) case 'D': // Long date (November 28, 2024)
formatted = date.toLocaleDateString([], { dateStyle: 'long' }); formatted = date.toLocaleDateString([], { dateStyle: 'long' });
break; break;
case 'f': // Short date/time (November 28, 2024 9:30 PM) case 'f': // Short date/time (November 28, 2024 9:30 PM)
default: default:
formatted = date.toLocaleString([], { dateStyle: 'long', timeStyle: 'short' }); formatted = date.toLocaleString([], { dateStyle: 'long', timeStyle: 'short' });
break; break;
case 'F': // Long date/time (Thursday, November 28, 2024 9:30 PM) case 'F': // Long date/time (Thursday, November 28, 2024 9:30 PM)
formatted = date.toLocaleString([], { dateStyle: 'full', timeStyle: 'short' }); formatted = date.toLocaleString([], { dateStyle: 'full', timeStyle: 'short' });
break; break;
case 'R': // Relative (2 hours ago) case 'R': // Relative (2 hours ago)
const now = Date.now(); const now = Date.now();
const diff = now - date.getTime(); const diff = now - date.getTime();
const seconds = Math.floor(Math.abs(diff) / 1000); const seconds = Math.floor(Math.abs(diff) / 1000);
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
const months = Math.floor(days / 30); const months = Math.floor(days / 30);
const years = Math.floor(days / 365); const years = Math.floor(days / 365);
if (diff < 0) { if (diff < 0) {
// Future // Future
if (years > 0) formatted = `in ${years} year${years > 1 ? 's' : ''}`; if (years > 0) formatted = `in ${years} year${years > 1 ? 's' : ''}`;
else if (months > 0) formatted = `in ${months} month${months > 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 (days > 0) formatted = `in ${days} day${days > 1 ? 's' : ''}`;
else if (hours > 0) formatted = `in ${hours} hour${hours > 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 if (minutes > 0) formatted = `in ${minutes} minute${minutes > 1 ? 's' : ''}`;
else formatted = `in ${seconds} second${seconds > 1 ? 's' : ''}`; else formatted = `in ${seconds} second${seconds > 1 ? 's' : ''}`;
} else { } else {
// Past // Past
if (years > 0) formatted = `${years} year${years > 1 ? 's' : ''} ago`; if (years > 0) formatted = `${years} year${years > 1 ? 's' : ''} ago`;
else if (months > 0) formatted = `${months} month${months > 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 (days > 0) formatted = `${days} day${days > 1 ? 's' : ''} ago`;
else if (hours > 0) formatted = `${hours} hour${hours > 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 if (minutes > 0) formatted = `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
else formatted = `${seconds} second${seconds > 1 ? 's' : ''} ago`; else formatted = `${seconds} second${seconds > 1 ? 's' : ''} ago`;
} }
break; 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)];
}
} }
const roleName = role?.name || 'role'; return `<span class="discord-timestamp" title="${date.toLocaleString()}">${formatted}</span>`;
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>`;
}
});
// Slash command mentions (</command:123456789>) // User mentions (<@123456789>)
parsed = parsed.replace(/&lt;\/([^:]+):(\d+)&gt;/g, '<span class="discord-slash-command">/$1</span>'); 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>) // @everyone and @here mentions
// Use cached emoji URL if available, otherwise fall back to Discord CDN parsed = parsed.replace(/@(everyone|here)/g, '<span class="discord-mention discord-mention-everyone">@$1</span>');
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}:">`;
});
// 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 // Role mentions (<@&123456789>) - robust lookup to avoid errors when rolesMap is missing or malformed
parsed = parsed.replace( parsed = parsed.replace(/&lt;@&amp;(\d+)&gt;/g, (_, roleId) => {
/\[([^\]]+)\]\((https?:\/\/[^\s)"]+)(?:\s+"([^"]+)")?\)/g, try {
(_, text, url, title) => { let role = null;
const titleAttr = title ? ` title="${title}"` : ''; if (rolesMap) {
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="discord-link"${titleAttr}>${text}</a>`; 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=") // Slash command mentions (</command:123456789>)
// Match URLs that are NOT preceded by =" or =' parsed = parsed.replace(/&lt;\/([^:]+):(\d+)&gt;/g, '<span class="discord-slash-command">/$1</span>');
parsed = parsed.replace(
/(?<![="'])(?<![=]["'])(https?:\/\/[^\s<>"']+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="discord-link">$1</a>'
);
// Newlines // Custom emoji (<:name:123456789> or <a:name:123456789>)
parsed = parsed.replace(/\n/g, '<br>'); // 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 (\\_ \\* \\~ \\` \\| \\\\) // Unicode emoji (keep as-is, they render natively)
// Must be done after all markdown processing
parsed = parsed.replace(/\\([_*~`|\\])/g, '$1');
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) { } catch (err) {
try { console.error('parseDiscordMarkdown failed', err); } catch (e) { /* ignore logging errors */ } 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 // 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 // Inline minimal JoyUI wrapper to avoid importing Components.jsx
function MinimalJoyWrapper({ children }) { function MinimalJoyWrapper({ children }) {
const cache = React.useRef(); const cache = React.useRef();
if (!cache.current) { if (!cache.current) {
cache.current = createCache({ key: "joy-sub" }); cache.current = createCache({ key: "joy-sub" });
} }
return ( return (
<CacheProvider value={cache.current}> <CacheProvider value={cache.current}>
<CssVarsProvider>{children}</CssVarsProvider> <CssVarsProvider>{children}</CssVarsProvider>
</CacheProvider> </CacheProvider>
); );
} }
export default function SubsiteRoot({ child, ...props }) { export default function SubsiteRoot({ child, ...props }) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.toast = toast; window.toast = toast;
} }
const theme = typeof document !== 'undefined' const theme = typeof document !== 'undefined'
? document.documentElement.getAttribute("data-theme") ? document.documentElement.getAttribute("data-theme")
: 'light'; : 'light';
return ( return (
<PrimeReactProvider> <PrimeReactProvider>
<ToastContainer <ToastContainer
position="top-right" position="top-right"
autoClose={5000} autoClose={5000}
hideProgressBar={false} hideProgressBar={false}
newestOnTop={true} newestOnTop={true}
closeOnClick={true} closeOnClick={true}
rtl={false} rtl={false}
pauseOnFocusLoss={false} pauseOnFocusLoss={false}
draggable draggable
pauseOnHover pauseOnHover
theme={theme === 'dark' ? 'dark' : 'light'} theme={theme === 'dark' ? 'dark' : 'light'}
/> />
<MinimalJoyWrapper> <MinimalJoyWrapper>
{child === "ReqForm" && ( {child === "ReqForm" && (
<Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>}> <Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>}>
<ReqForm {...props} /> <ReqForm {...props} />
</Suspense> </Suspense>
)} )}
</MinimalJoyWrapper> </MinimalJoyWrapper>
</PrimeReactProvider> </PrimeReactProvider>
); );
} }