snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 1s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 1s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
This commit is contained in:
72
packages/gazzodown/src/elements/BoldSpan.tsx
Normal file
72
packages/gazzodown/src/elements/BoldSpan.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type * as MessageParser from '@rocket.chat/message-parser';
|
||||
|
||||
import ItalicSpan from './ItalicSpan';
|
||||
import LinkSpan from './LinkSpan';
|
||||
import PlainSpan from './PlainSpan';
|
||||
import StrikeSpan from './StrikeSpan';
|
||||
import CodeElement from '../code/CodeElement';
|
||||
import EmojiElement from '../emoji/EmojiElement';
|
||||
import ChannelMentionElement from '../mentions/ChannelMentionElement';
|
||||
import UserMentionElement from '../mentions/UserMentionElement';
|
||||
|
||||
type MessageBlock =
|
||||
| MessageParser.Emoji
|
||||
| MessageParser.ChannelMention
|
||||
| MessageParser.UserMention
|
||||
| MessageParser.Link
|
||||
| MessageParser.MarkupExcluding<MessageParser.Bold>
|
||||
| MessageParser.InlineCode;
|
||||
|
||||
export type BoldSpanProps = {
|
||||
children: MessageBlock[];
|
||||
};
|
||||
|
||||
const BoldSpan = ({ children }: BoldSpanProps) => (
|
||||
<>
|
||||
{children.map((block, index) => {
|
||||
if (
|
||||
block.type === 'LINK' ||
|
||||
block.type === 'PLAIN_TEXT' ||
|
||||
block.type === 'STRIKE' ||
|
||||
block.type === 'ITALIC' ||
|
||||
block.type === 'INLINE_CODE'
|
||||
) {
|
||||
return <strong key={index}>{renderBlockComponent(block, index)}</strong>;
|
||||
}
|
||||
return renderBlockComponent(block, index);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderBlockComponent = (block: MessageBlock, index: number) => {
|
||||
switch (block.type) {
|
||||
case 'EMOJI':
|
||||
return <EmojiElement key={index} {...block} />;
|
||||
|
||||
case 'MENTION_USER':
|
||||
return <UserMentionElement key={index} mention={block.value.value} />;
|
||||
|
||||
case 'MENTION_CHANNEL':
|
||||
return <ChannelMentionElement key={index} mention={block.value.value} />;
|
||||
|
||||
case 'PLAIN_TEXT':
|
||||
return <PlainSpan key={index} text={block.value} />;
|
||||
|
||||
case 'LINK':
|
||||
return <LinkSpan key={index} href={block.value.src.value} label={block.value.label} />;
|
||||
|
||||
case 'STRIKE':
|
||||
return <StrikeSpan key={index}>{block.value}</StrikeSpan>;
|
||||
|
||||
case 'ITALIC':
|
||||
return <ItalicSpan key={index}>{block.value}</ItalicSpan>;
|
||||
|
||||
case 'INLINE_CODE':
|
||||
return <CodeElement key={index} code={block.value.value} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default BoldSpan;
|
||||
60
packages/gazzodown/src/elements/ImageElement.tsx
Normal file
60
packages/gazzodown/src/elements/ImageElement.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import type * as MessageParser from '@rocket.chat/message-parser';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { sanitizeUrl } from './sanitizeUrl';
|
||||
|
||||
const flattenMarkup = (
|
||||
markup:
|
||||
| MessageParser.Timestamp
|
||||
| MessageParser.Markup
|
||||
| MessageParser.InlineCode
|
||||
| MessageParser.Link
|
||||
| MessageParser.Emoji
|
||||
| MessageParser.ChannelMention
|
||||
| MessageParser.UserMention,
|
||||
): string => {
|
||||
switch (markup.type) {
|
||||
case 'PLAIN_TEXT':
|
||||
return markup.value;
|
||||
|
||||
case 'ITALIC':
|
||||
case 'BOLD':
|
||||
case 'STRIKE':
|
||||
return markup.value.map(flattenMarkup).join('');
|
||||
|
||||
case 'INLINE_CODE':
|
||||
return flattenMarkup(markup.value);
|
||||
|
||||
case 'LINK': {
|
||||
const label = flattenMarkup(markup.value.label as MessageParser.Markup);
|
||||
const href = markup.value.src.value;
|
||||
|
||||
return label ? `${label} (${href})` : href;
|
||||
}
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const style = {
|
||||
maxWidth: '100%',
|
||||
};
|
||||
|
||||
export type ImageElementProps = {
|
||||
src: string;
|
||||
alt: MessageParser.Markup;
|
||||
};
|
||||
|
||||
const ImageElement = ({ src, alt }: ImageElementProps) => {
|
||||
const plainAlt = useMemo(() => flattenMarkup(alt), [alt]);
|
||||
const safeSrc = sanitizeUrl(src);
|
||||
|
||||
return (
|
||||
<a href={safeSrc} target='_blank' rel='noopener noreferrer' title={plainAlt}>
|
||||
<img src={safeSrc} data-title={safeSrc} alt={plainAlt} style={style} />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageElement;
|
||||
93
packages/gazzodown/src/elements/InlineElements.tsx
Normal file
93
packages/gazzodown/src/elements/InlineElements.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type * as MessageParser from '@rocket.chat/message-parser';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import BoldSpan from './BoldSpan';
|
||||
import ImageElement from './ImageElement';
|
||||
import ItalicSpan from './ItalicSpan';
|
||||
import LinkSpan from './LinkSpan';
|
||||
import PlainSpan from './PlainSpan';
|
||||
import SpoilerSpan from './SpoilerSpan';
|
||||
import StrikeSpan from './StrikeSpan';
|
||||
import Timestamp from './Timestamp';
|
||||
import CodeElement from '../code/CodeElement';
|
||||
import ColorElement from '../colors/ColorElement';
|
||||
import EmojiElement from '../emoji/EmojiElement';
|
||||
import KatexErrorBoundary from '../katex/KatexErrorBoundary';
|
||||
import ChannelMentionElement from '../mentions/ChannelMentionElement';
|
||||
import UserMentionElement from '../mentions/UserMentionElement';
|
||||
|
||||
const KatexElement = lazy(() => import('../katex/KatexElement'));
|
||||
|
||||
export type InlineElementsProps = {
|
||||
children: (MessageParser.Inlines | { fallback: MessageParser.Plain; type: undefined })[];
|
||||
};
|
||||
|
||||
const InlineElements = ({ children }: InlineElementsProps) => (
|
||||
<>
|
||||
{children.map((child, index) => {
|
||||
switch (child.type) {
|
||||
case 'BOLD':
|
||||
return <BoldSpan key={index}>{child.value}</BoldSpan>;
|
||||
|
||||
case 'STRIKE':
|
||||
return <StrikeSpan key={index}>{child.value}</StrikeSpan>;
|
||||
|
||||
case 'ITALIC':
|
||||
return <ItalicSpan key={index}>{child.value}</ItalicSpan>;
|
||||
|
||||
case 'SPOILER':
|
||||
return <SpoilerSpan key={index}>{child.value}</SpoilerSpan>;
|
||||
|
||||
case 'LINK':
|
||||
return (
|
||||
<LinkSpan
|
||||
key={index}
|
||||
href={child.value.src.value}
|
||||
label={Array.isArray(child.value.label) ? child.value.label : [child.value.label]}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'PLAIN_TEXT':
|
||||
return <PlainSpan key={index} text={child.value} />;
|
||||
|
||||
case 'IMAGE':
|
||||
return <ImageElement key={index} src={child.value.src.value} alt={child.value.label} />;
|
||||
|
||||
case 'MENTION_USER':
|
||||
return <UserMentionElement key={index} mention={child.value.value} />;
|
||||
|
||||
case 'MENTION_CHANNEL':
|
||||
return <ChannelMentionElement key={index} mention={child.value.value} />;
|
||||
|
||||
case 'INLINE_CODE':
|
||||
return <CodeElement key={index} code={child.value.value} />;
|
||||
|
||||
case 'EMOJI':
|
||||
return <EmojiElement key={index} {...child} />;
|
||||
|
||||
case 'COLOR':
|
||||
return <ColorElement key={index} {...child.value} />;
|
||||
|
||||
case 'INLINE_KATEX':
|
||||
return (
|
||||
<KatexErrorBoundary key={index} code={child.value}>
|
||||
<KatexElement code={child.value} />
|
||||
</KatexErrorBoundary>
|
||||
);
|
||||
|
||||
case 'TIMESTAMP': {
|
||||
return <Timestamp key={index}>{child}</Timestamp>;
|
||||
}
|
||||
|
||||
default: {
|
||||
if ('fallback' in child) {
|
||||
return <InlineElements key={index}>{[child.fallback]}</InlineElements>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
export default InlineElements;
|
||||
72
packages/gazzodown/src/elements/ItalicSpan.tsx
Normal file
72
packages/gazzodown/src/elements/ItalicSpan.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type * as MessageParser from '@rocket.chat/message-parser';
|
||||
|
||||
import BoldSpan from './BoldSpan';
|
||||
import LinkSpan from './LinkSpan';
|
||||
import PlainSpan from './PlainSpan';
|
||||
import StrikeSpan from './StrikeSpan';
|
||||
import CodeElement from '../code/CodeElement';
|
||||
import EmojiElement from '../emoji/EmojiElement';
|
||||
import ChannelMentionElement from '../mentions/ChannelMentionElement';
|
||||
import UserMentionElement from '../mentions/UserMentionElement';
|
||||
|
||||
type MessageBlock =
|
||||
| MessageParser.Emoji
|
||||
| MessageParser.ChannelMention
|
||||
| MessageParser.UserMention
|
||||
| MessageParser.Link
|
||||
| MessageParser.MarkupExcluding<MessageParser.Italic>
|
||||
| MessageParser.InlineCode;
|
||||
|
||||
export type ItalicSpanProps = {
|
||||
children: MessageBlock[];
|
||||
};
|
||||
|
||||
const ItalicSpan = ({ children }: ItalicSpanProps) => (
|
||||
<>
|
||||
{children.map((block, index) => {
|
||||
if (
|
||||
block.type === 'LINK' ||
|
||||
block.type === 'PLAIN_TEXT' ||
|
||||
block.type === 'STRIKE' ||
|
||||
block.type === 'BOLD' ||
|
||||
block.type === 'INLINE_CODE'
|
||||
) {
|
||||
return <em key={index}>{renderBlockComponent(block, index)}</em>;
|
||||
}
|
||||
return renderBlockComponent(block, index);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderBlockComponent = (block: MessageBlock, index: number) => {
|
||||
switch (block.type) {
|
||||
case 'EMOJI':
|
||||
return <EmojiElement key={index} {...block} />;
|
||||
|
||||
case 'MENTION_USER':
|
||||
return <UserMentionElement key={index} mention={block.value.value} />;
|
||||
|
||||
case 'MENTION_CHANNEL':
|
||||
return <ChannelMentionElement key={index} mention={block.value.value} />;
|
||||
|
||||
case 'PLAIN_TEXT':
|
||||
return <PlainSpan key={index} text={block.value} />;
|
||||
|
||||
case 'LINK':
|
||||
return <LinkSpan key={index} href={block.value.src.value} label={block.value.label} />;
|
||||
|
||||
case 'STRIKE':
|
||||
return <StrikeSpan key={index}>{block.value}</StrikeSpan>;
|
||||
|
||||
case 'BOLD':
|
||||
return <BoldSpan key={index}>{block.value}</BoldSpan>;
|
||||
|
||||
case 'INLINE_CODE':
|
||||
return <CodeElement key={index} code={block.value.value} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default ItalicSpan;
|
||||
62
packages/gazzodown/src/elements/LinkSpan.tsx
Normal file
62
packages/gazzodown/src/elements/LinkSpan.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type * as MessageParser from '@rocket.chat/message-parser';
|
||||
import { getBaseURI, isExternal } from '@rocket.chat/ui-client';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import BoldSpan from './BoldSpan';
|
||||
import ItalicSpan from './ItalicSpan';
|
||||
import PlainSpan from './PlainSpan';
|
||||
import StrikeSpan from './StrikeSpan';
|
||||
import { sanitizeUrl } from './sanitizeUrl';
|
||||
|
||||
export type LinkSpanProps = {
|
||||
href: string;
|
||||
label: MessageParser.Markup | MessageParser.Markup[];
|
||||
};
|
||||
|
||||
const LinkSpan = ({ href, label }: LinkSpanProps) => {
|
||||
// Should sanitize 'href' if any of the insecure prefixes are present - see DSK-34 on Jira
|
||||
const sanitizedHref = sanitizeUrl(href);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const children = useMemo(() => {
|
||||
const labelArray = Array.isArray(label) ? label : [label];
|
||||
|
||||
const labelElements = labelArray.map((child, index) => {
|
||||
switch (child.type) {
|
||||
case 'PLAIN_TEXT':
|
||||
return <PlainSpan key={index} text={child.value} />;
|
||||
|
||||
case 'STRIKE':
|
||||
return <StrikeSpan key={index}>{child.value}</StrikeSpan>;
|
||||
|
||||
case 'ITALIC':
|
||||
return <ItalicSpan key={index}>{child.value}</ItalicSpan>;
|
||||
|
||||
case 'BOLD':
|
||||
return <BoldSpan key={index}>{child.value}</BoldSpan>;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return labelElements;
|
||||
}, [label]);
|
||||
|
||||
if (isExternal(sanitizedHref)) {
|
||||
return (
|
||||
<a href={sanitizedHref} title={sanitizedHref} rel='noopener noreferrer' target='_blank'>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={sanitizedHref} title={t('Go_to_href', { href: sanitizedHref.replace(getBaseURI(), '') })}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkSpan;
|
||||
61
packages/gazzodown/src/elements/PlainSpan.tsx
Normal file
61
packages/gazzodown/src/elements/PlainSpan.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Fragment, memo, useContext, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MarkupInteractionContext } from '../MarkupInteractionContext';
|
||||
|
||||
export type PlainSpanProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
const PlainSpan = ({ text }: PlainSpanProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { highlightRegex, markRegex } = useContext(MarkupInteractionContext);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (highlightRegex) {
|
||||
const chunks = text.split(highlightRegex());
|
||||
const head = chunks.shift() ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<>{head}</>
|
||||
{chunks.map((chunk, i) => {
|
||||
if (i % 2 === 0) {
|
||||
return (
|
||||
<mark title={t('Highlighted_chosen_word')} key={i} className='highlight-text'>
|
||||
{chunk}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
|
||||
return <Fragment key={i}>{chunk}</Fragment>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (markRegex) {
|
||||
const chunks = text.split(markRegex());
|
||||
const head = chunks.shift() ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<>{head}</>
|
||||
{chunks.map((chunk, i) => {
|
||||
if (i % 2 === 0) {
|
||||
return <mark key={i}>{chunk}</mark>;
|
||||
}
|
||||
|
||||
return <Fragment key={i}>{chunk}</Fragment>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return text;
|
||||
}, [highlightRegex, markRegex, text, t]);
|
||||
|
||||
return <>{content}</>;
|
||||
};
|
||||
|
||||
export default memo(PlainSpan);
|
||||
77
packages/gazzodown/src/elements/PreviewInlineElements.tsx
Normal file
77
packages/gazzodown/src/elements/PreviewInlineElements.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type * as MessageParser from '@rocket.chat/message-parser';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
import BoldSpan from './BoldSpan';
|
||||
import ItalicSpan from './ItalicSpan';
|
||||
import StrikeSpan from './StrikeSpan';
|
||||
import PreviewCodeElement from '../code/PreviewCodeElement';
|
||||
import PreviewColorElement from '../colors/PreviewColorElement';
|
||||
import PreviewEmojiElement from '../emoji/PreviewEmojiElement';
|
||||
import KatexErrorBoundary from '../katex/KatexErrorBoundary';
|
||||
import PreviewKatexElement from '../katex/PreviewKatexElement';
|
||||
import PreviewChannelMentionElement from '../mentions/PreviewChannelMentionElement';
|
||||
import PreviewUserMentionElement from '../mentions/PreviewUserMentionElement';
|
||||
|
||||
export type PreviewInlineElementsProps = {
|
||||
children: MessageParser.Inlines[];
|
||||
};
|
||||
|
||||
const PreviewInlineElements = ({ children }: PreviewInlineElementsProps) => (
|
||||
<>
|
||||
{children.map((child, index) => {
|
||||
switch (child.type) {
|
||||
case 'BOLD':
|
||||
return <BoldSpan key={index}>{child.value}</BoldSpan>;
|
||||
|
||||
case 'STRIKE':
|
||||
return <StrikeSpan key={index}>{child.value}</StrikeSpan>;
|
||||
|
||||
case 'ITALIC':
|
||||
return <ItalicSpan key={index}>{child.value}</ItalicSpan>;
|
||||
|
||||
case 'SPOILER':
|
||||
return <PreviewInlineElements key={index}>{child.value}</PreviewInlineElements>;
|
||||
|
||||
case 'LINK':
|
||||
return (
|
||||
<PreviewInlineElements key={index}>
|
||||
{Array.isArray(child.value.label) ? child.value.label : [child.value.label]}
|
||||
</PreviewInlineElements>
|
||||
);
|
||||
|
||||
case 'PLAIN_TEXT':
|
||||
return <Fragment key={index}>{child.value}</Fragment>;
|
||||
|
||||
case 'IMAGE':
|
||||
return <PreviewInlineElements key={index}>{[child.value.label]}</PreviewInlineElements>;
|
||||
|
||||
case 'MENTION_USER':
|
||||
return <PreviewUserMentionElement key={index} mention={child.value.value} />;
|
||||
|
||||
case 'MENTION_CHANNEL':
|
||||
return <PreviewChannelMentionElement key={index} mention={child.value.value} />;
|
||||
|
||||
case 'INLINE_CODE':
|
||||
return <PreviewCodeElement key={index} code={child.value.value} />;
|
||||
|
||||
case 'EMOJI':
|
||||
return <PreviewEmojiElement key={index} {...child} />;
|
||||
|
||||
case 'COLOR':
|
||||
return <PreviewColorElement key={index} {...child.value} />;
|
||||
|
||||
case 'INLINE_KATEX':
|
||||
return (
|
||||
<KatexErrorBoundary key={index} code={child.value}>
|
||||
<PreviewKatexElement code={child.value} />
|
||||
</KatexErrorBoundary>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
export default PreviewInlineElements;
|
||||
137
packages/gazzodown/src/elements/SpoilerSpan.tsx
Normal file
137
packages/gazzodown/src/elements/SpoilerSpan.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import type * as MessageParser from '@rocket.chat/message-parser';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { lazy, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import BoldSpan from './BoldSpan';
|
||||
import ImageElement from './ImageElement';
|
||||
import ItalicSpan from './ItalicSpan';
|
||||
import LinkSpan from './LinkSpan';
|
||||
import PlainSpan from './PlainSpan';
|
||||
import StrikeSpan from './StrikeSpan';
|
||||
import Timestamp from './Timestamp';
|
||||
import CodeElement from '../code/CodeElement';
|
||||
import ColorElement from '../colors/ColorElement';
|
||||
import EmojiElement from '../emoji/EmojiElement';
|
||||
import KatexErrorBoundary from '../katex/KatexErrorBoundary';
|
||||
import ChannelMentionElement from '../mentions/ChannelMentionElement';
|
||||
import UserMentionElement from '../mentions/UserMentionElement';
|
||||
|
||||
const KatexElement = lazy(() => import('../katex/KatexElement'));
|
||||
|
||||
export type SpoilerSpanProps = {
|
||||
children: MessageParser.Spoiler['value'];
|
||||
};
|
||||
|
||||
const spoilerStyle = {
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
borderRadius: 2,
|
||||
paddingInline: 2,
|
||||
filter: 'blur(4px)',
|
||||
transition: 'filter 230ms ease',
|
||||
} as const;
|
||||
|
||||
const revealedStyle = {
|
||||
filter: 'none',
|
||||
transition: 'filter 230ms ease',
|
||||
} as const;
|
||||
|
||||
const srOnlyStyle = {
|
||||
border: 0,
|
||||
clip: 'rect(0 0 0 0)',
|
||||
height: 1,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
position: 'absolute',
|
||||
whiteSpace: 'nowrap',
|
||||
width: 1,
|
||||
} as const;
|
||||
|
||||
const SpoilerSpan = ({ children }: SpoilerSpanProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
const reveal = useCallback(() => {
|
||||
setRevealed(true);
|
||||
}, []);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
reveal();
|
||||
}
|
||||
},
|
||||
[reveal],
|
||||
);
|
||||
|
||||
const srText = t('Spoiler_hidden_activate_to_reveal', { defaultValue: 'Spoiler hidden. Activate to reveal.' });
|
||||
|
||||
if (revealed) {
|
||||
return <span style={revealedStyle}>{children.map((block, index) => renderBlockComponent(block, index))}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span role='button' tabIndex={0} aria-expanded={false} aria-label={srText} onClick={reveal} onKeyDown={onKeyDown} style={spoilerStyle}>
|
||||
<span style={srOnlyStyle}>{srText}</span>
|
||||
<span aria-hidden>{children.map((block, index) => renderBlockComponent(block, index))}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBlockComponent = (block: MessageParser.Inlines, index: number) => {
|
||||
switch (block.type) {
|
||||
case 'EMOJI':
|
||||
return <EmojiElement key={index} {...block} />;
|
||||
|
||||
case 'MENTION_USER':
|
||||
return <UserMentionElement key={index} mention={block.value.value} />;
|
||||
|
||||
case 'MENTION_CHANNEL':
|
||||
return <ChannelMentionElement key={index} mention={block.value.value} />;
|
||||
|
||||
case 'PLAIN_TEXT':
|
||||
return <PlainSpan key={index} text={block.value} />;
|
||||
|
||||
case 'LINK':
|
||||
return <LinkSpan key={index} href={block.value.src.value} label={block.value.label} />;
|
||||
|
||||
case 'STRIKE':
|
||||
return <StrikeSpan key={index}>{block.value}</StrikeSpan>;
|
||||
|
||||
case 'ITALIC':
|
||||
return <ItalicSpan key={index}>{block.value}</ItalicSpan>;
|
||||
|
||||
case 'BOLD':
|
||||
return <BoldSpan key={index}>{block.value}</BoldSpan>;
|
||||
|
||||
case 'INLINE_CODE':
|
||||
return <CodeElement key={index} code={block.value.value} />;
|
||||
|
||||
case 'SPOILER':
|
||||
return <SpoilerSpan key={index}>{block.value}</SpoilerSpan>;
|
||||
|
||||
case 'TIMESTAMP':
|
||||
return <Timestamp key={index}>{block}</Timestamp>;
|
||||
|
||||
case 'COLOR':
|
||||
return <ColorElement key={index} {...block.value} />;
|
||||
|
||||
case 'IMAGE':
|
||||
return <ImageElement key={index} src={block.value.src.value} alt={block.value.label} />;
|
||||
|
||||
case 'INLINE_KATEX':
|
||||
return (
|
||||
<KatexErrorBoundary key={index} code={block.value}>
|
||||
<KatexElement code={block.value} />
|
||||
</KatexErrorBoundary>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default SpoilerSpan;
|
||||
73
packages/gazzodown/src/elements/StrikeSpan.tsx
Normal file
73
packages/gazzodown/src/elements/StrikeSpan.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type * as MessageParser from '@rocket.chat/message-parser';
|
||||
|
||||
import BoldSpan from './BoldSpan';
|
||||
import ItalicSpan from './ItalicSpan';
|
||||
import LinkSpan from './LinkSpan';
|
||||
import PlainSpan from './PlainSpan';
|
||||
import CodeElement from '../code/CodeElement';
|
||||
import EmojiElement from '../emoji/EmojiElement';
|
||||
import ChannelMentionElement from '../mentions/ChannelMentionElement';
|
||||
import UserMentionElement from '../mentions/UserMentionElement';
|
||||
|
||||
type MessageBlock =
|
||||
| MessageParser.Timestamp
|
||||
| MessageParser.Emoji
|
||||
| MessageParser.ChannelMention
|
||||
| MessageParser.UserMention
|
||||
| MessageParser.Link
|
||||
| MessageParser.MarkupExcluding<MessageParser.Strike>
|
||||
| MessageParser.InlineCode;
|
||||
|
||||
export type StrikeSpanProps = {
|
||||
children: MessageBlock[];
|
||||
};
|
||||
|
||||
const StrikeSpan = ({ children }: StrikeSpanProps) => (
|
||||
<>
|
||||
{children.map((block, index) => {
|
||||
if (
|
||||
block.type === 'LINK' ||
|
||||
block.type === 'PLAIN_TEXT' ||
|
||||
block.type === 'ITALIC' ||
|
||||
block.type === 'BOLD' ||
|
||||
block.type === 'INLINE_CODE'
|
||||
) {
|
||||
return <del key={index}>{renderBlockComponent(block, index)}</del>;
|
||||
}
|
||||
return renderBlockComponent(block, index);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderBlockComponent = (block: MessageBlock, index: number) => {
|
||||
switch (block.type) {
|
||||
case 'EMOJI':
|
||||
return <EmojiElement key={index} {...block} />;
|
||||
|
||||
case 'MENTION_USER':
|
||||
return <UserMentionElement key={index} mention={block.value.value} />;
|
||||
|
||||
case 'MENTION_CHANNEL':
|
||||
return <ChannelMentionElement key={index} mention={block.value.value} />;
|
||||
|
||||
case 'PLAIN_TEXT':
|
||||
return <PlainSpan key={index} text={block.value} />;
|
||||
|
||||
case 'LINK':
|
||||
return <LinkSpan key={index} href={block.value.src.value} label={block.value.label} />;
|
||||
|
||||
case 'ITALIC':
|
||||
return <ItalicSpan key={index}>{block.value}</ItalicSpan>;
|
||||
|
||||
case 'BOLD':
|
||||
return <BoldSpan key={index}>{block.value}</BoldSpan>;
|
||||
|
||||
case 'INLINE_CODE':
|
||||
return <CodeElement key={index} code={block.value.value} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default StrikeSpan;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
|
||||
import TimestampWrapper from './index';
|
||||
import { MarkupInteractionContext } from '../../MarkupInteractionContext';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const makeTimestampNode = (epochSeconds: number) =>
|
||||
({
|
||||
type: 'TIMESTAMP' as const,
|
||||
value: {
|
||||
timestamp: String(epochSeconds),
|
||||
format: 'R' as const,
|
||||
},
|
||||
}) satisfies Parameters<typeof TimestampWrapper>[0]['children'];
|
||||
|
||||
it('should update displayed text immediately when the value prop changes', () => {
|
||||
const now = new Date('2026-05-12T12:00:00.000Z');
|
||||
jest.setSystemTime(now);
|
||||
|
||||
const futureA = Math.floor(now.getTime() / 1000) + 7200;
|
||||
const futureB = Math.floor(now.getTime() / 1000) + 86400;
|
||||
|
||||
const { rerender } = render(<TimestampWrapper>{makeTimestampNode(futureA)}</TimestampWrapper>);
|
||||
|
||||
const tagA = screen.getByText(/2 hours/i);
|
||||
expect(tagA).toBeInTheDocument();
|
||||
|
||||
rerender(<TimestampWrapper>{makeTimestampNode(futureB)}</TimestampWrapper>);
|
||||
|
||||
const tagB = screen.getByText(/tomorrow/i);
|
||||
expect(tagB).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should preserve the locale from context after interval refresh', () => {
|
||||
const now = new Date('2026-05-12T12:00:00.000Z');
|
||||
jest.setSystemTime(now);
|
||||
|
||||
const futureEpoch = Math.floor(now.getTime() / 1000) + 30;
|
||||
|
||||
render(
|
||||
<MarkupInteractionContext.Provider value={{ language: 'de' }}>
|
||||
<TimestampWrapper>{makeTimestampNode(futureEpoch)}</TimestampWrapper>
|
||||
</MarkupInteractionContext.Provider>,
|
||||
);
|
||||
|
||||
const initial = screen.getByRole('time');
|
||||
const initialText = initial.textContent;
|
||||
expect(initialText).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1500);
|
||||
});
|
||||
|
||||
const afterTick = screen.getByRole('time');
|
||||
const afterTickText = afterTick.textContent;
|
||||
expect(afterTickText).toBeTruthy();
|
||||
|
||||
expect(afterTickText).not.toMatch(/seconds/i);
|
||||
});
|
||||
|
||||
it('should update displayed text immediately when locale changes via context', () => {
|
||||
const now = new Date('2026-05-12T12:00:00.000Z');
|
||||
jest.setSystemTime(now);
|
||||
|
||||
const futureEpoch = Math.floor(now.getTime() / 1000) + 30;
|
||||
|
||||
const { rerender } = render(
|
||||
<MarkupInteractionContext.Provider value={{ language: 'en' }}>
|
||||
<TimestampWrapper>{makeTimestampNode(futureEpoch)}</TimestampWrapper>
|
||||
</MarkupInteractionContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('time').textContent).toMatch(/seconds/i);
|
||||
|
||||
rerender(
|
||||
<MarkupInteractionContext.Provider value={{ language: 'de' }}>
|
||||
<TimestampWrapper>{makeTimestampNode(futureEpoch)}</TimestampWrapper>
|
||||
</MarkupInteractionContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('time').textContent).not.toMatch(/seconds/i);
|
||||
});
|
||||
141
packages/gazzodown/src/elements/Timestamp/index.tsx
Normal file
141
packages/gazzodown/src/elements/Timestamp/index.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Tag } from '@rocket.chat/fuselage';
|
||||
import type * as MessageParser from '@rocket.chat/message-parser';
|
||||
import { format, intlFormatDistance } from 'date-fns';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
import { MarkupInteractionContext } from '../../MarkupInteractionContext';
|
||||
|
||||
type BoldSpanProps = {
|
||||
children: MessageParser.Timestamp;
|
||||
};
|
||||
|
||||
export type TimestampProps = { format: 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R'; value: Date };
|
||||
|
||||
const Timestamp = ({ format, value }: TimestampProps) => {
|
||||
switch (format) {
|
||||
case 't': // Short time format
|
||||
return <ShortTime value={value} />;
|
||||
case 'T': // Long time format
|
||||
return <LongTime value={value} />;
|
||||
case 'd': // Short date format
|
||||
return <ShortDate value={value} />;
|
||||
case 'D': // Long date format
|
||||
return <LongDate value={value} />;
|
||||
case 'f': // Full date and time format
|
||||
return <FullDate value={value} />;
|
||||
|
||||
case 'F': // Full date and time (long) format
|
||||
return <FullDateLong value={value} />;
|
||||
|
||||
case 'R': // Relative time format
|
||||
return <RelativeTime value={value} />;
|
||||
|
||||
default:
|
||||
return <time dateTime={value.toISOString()}> {JSON.stringify(value.getTime())}</time>;
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const TimestampWrapper = ({ children }: BoldSpanProps) => (
|
||||
<ErrorBoundary fallback={<>{new Date(parseInt(children.value.timestamp) * 1000).toUTCString()}</>}>
|
||||
<Timestamp format={children.value.format} value={new Date(parseInt(children.value.timestamp) * 1000)} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
export type ShortTimeProps = { value: Date };
|
||||
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const ShortTime = ({ value }: ShortTimeProps) => <Time value={format(value, 'p')} dateTime={value.toISOString()} />;
|
||||
|
||||
export type LongTimeProps = { value: Date };
|
||||
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const LongTime = ({ value }: LongTimeProps) => <Time value={format(value, 'pp')} dateTime={value.toISOString()} />;
|
||||
|
||||
export type ShortDateProps = { value: Date };
|
||||
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const ShortDate = ({ value }: ShortDateProps) => <Time value={format(value, 'P')} dateTime={value.toISOString()} />;
|
||||
|
||||
export type LongDateProps = { value: Date };
|
||||
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const LongDate = ({ value }: LongDateProps) => <Time value={format(value, 'Pp')} dateTime={value.toISOString()} />;
|
||||
|
||||
export type FullDateProps = { value: Date };
|
||||
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const FullDate = ({ value }: FullDateProps) => <Time value={format(value, 'PPPP p')} dateTime={value.toISOString()} />;
|
||||
|
||||
export type FullDateLongProps = { value: Date };
|
||||
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const FullDateLong = ({ value }: FullDateLongProps) => <Time value={format(value, 'PPPP pp')} dateTime={value.toISOString()} />;
|
||||
|
||||
export type TimeProps = { value: string; dateTime: string };
|
||||
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const Time = ({ value, dateTime }: TimeProps) => (
|
||||
<time
|
||||
title={new Date(dateTime).toLocaleString()}
|
||||
dateTime={dateTime}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
<Tag>{value}</Tag>
|
||||
</time>
|
||||
);
|
||||
|
||||
export type RelativeTimeProps = { value: Date };
|
||||
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const RelativeTime = ({ value }: RelativeTimeProps) => {
|
||||
const time = value.getTime();
|
||||
|
||||
const { language } = useContext(MarkupInteractionContext);
|
||||
const locale = language ?? 'en';
|
||||
const [text, setText] = useState(() => intlFormatDistance(time, Date.now(), { locale }));
|
||||
|
||||
useEffect(() => {
|
||||
setText(intlFormatDistance(time, Date.now(), { locale }));
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const refresh = () => {
|
||||
setText(intlFormatDistance(time, Date.now(), { locale }));
|
||||
timeoutId = setTimeout(refresh, getTimeToRefresh(time));
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(refresh, getTimeToRefresh(time));
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [time, locale]);
|
||||
|
||||
return <Time value={text} dateTime={value.toISOString()} />;
|
||||
};
|
||||
|
||||
const getTimeToRefresh = (time: number): number => {
|
||||
const timeToRefresh = time - Date.now();
|
||||
|
||||
// less than 1 minute
|
||||
if (timeToRefresh < 60000) {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
// if the difference is in the minutes range, we should refresh the time in 1 minute / 2
|
||||
if (timeToRefresh < 3600000) {
|
||||
return 60000 / 2;
|
||||
}
|
||||
|
||||
// if the difference is in the hours range, we should refresh the time in 5 minutes
|
||||
if (timeToRefresh < 86400000) {
|
||||
return 60000 * 5;
|
||||
}
|
||||
|
||||
// refresh the time in 1 hour
|
||||
return 3600000;
|
||||
};
|
||||
|
||||
export default TimestampWrapper;
|
||||
109
packages/gazzodown/src/elements/sanitizeUrl.spec.ts
Normal file
109
packages/gazzodown/src/elements/sanitizeUrl.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { sanitizeUrl } from './sanitizeUrl';
|
||||
|
||||
describe('sanitizeUrl', () => {
|
||||
it('allows safe HTTP URLs', () => {
|
||||
expect(sanitizeUrl('http://example.com')).toBe('http://example.com/');
|
||||
});
|
||||
|
||||
it('allows safe HTTPS URLs', () => {
|
||||
expect(sanitizeUrl('https://secure.com')).toBe('https://secure.com/');
|
||||
});
|
||||
|
||||
it('allows safe URLs with query parameters', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'https://example.com/?q=test',
|
||||
expected: 'https://example.com/?q=test',
|
||||
},
|
||||
{
|
||||
input: 'http://example.com/search?query=hello+world',
|
||||
expected: 'http://example.com/search?query=hello+world',
|
||||
},
|
||||
{
|
||||
input: 'https://example.com/path?param1=value1¶m2=value2',
|
||||
expected: 'https://example.com/path?param1=value1¶m2=value2',
|
||||
},
|
||||
{
|
||||
input: 'https://example.com/?redirect=http://safe.com',
|
||||
expected: 'https://example.com/?redirect=http://safe.com',
|
||||
},
|
||||
|
||||
{
|
||||
input: 'https://example.com/?xss=%3Cscript%3Ealert(1)%3C%2Fscript%3E',
|
||||
expected: 'https://example.com/?xss=%3Cscript%3Ealert(1)%3C%2Fscript%3E',
|
||||
},
|
||||
{
|
||||
input: 'https://example.com/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E',
|
||||
expected: 'https://example.com/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E',
|
||||
},
|
||||
{
|
||||
input: 'https://example.com/?param1=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E',
|
||||
expected: 'https://example.com/?param1=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E',
|
||||
},
|
||||
{
|
||||
input: 'https://example.com/?q=%3Cimg+src%3D%22javascript%3Aalert(1)%22%3E',
|
||||
expected: 'https://example.com/?q=%3Cimg+src%3D%22javascript%3Aalert(1)%22%3E',
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
expect(sanitizeUrl(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeUrl - XSS Payloads', () => {
|
||||
it('sanitizes javascript: URLs', () => {
|
||||
const payloads = [
|
||||
'javascript:alert(1)',
|
||||
'javascript:confirm("XSS")',
|
||||
'javascript:/*XSS*/alert(1)',
|
||||
'javascript:eval("alert(1)")',
|
||||
'javascript:window.location="http://evil.com"',
|
||||
];
|
||||
|
||||
payloads.forEach((payload) => {
|
||||
expect(sanitizeUrl(payload)).toBe('#');
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes data: URLs', () => {
|
||||
const payloads = [
|
||||
'data:text/html,<script>alert("XSS")</script>',
|
||||
'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==',
|
||||
'data:image/svg+xml,<svg onload=alert(1)>',
|
||||
'data:text/html,<iframe src="javascript:alert(1)"></iframe>',
|
||||
'data:text/html;charset=utf-8,<script>confirm("XSS")</script>',
|
||||
];
|
||||
|
||||
payloads.forEach((payload) => {
|
||||
expect(sanitizeUrl(payload)).toBe('#');
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes vbscript: URLs', () => {
|
||||
const payloads = [
|
||||
'vbscript:msgbox("XSS")',
|
||||
'vbscript:alert("Hello")',
|
||||
'vbscript:Execute("msgbox(123)")',
|
||||
'VBScript:CreateObject("WScript.Shell").Run("calc")',
|
||||
'VBSCRIPT:MsgBox("payload")',
|
||||
];
|
||||
|
||||
payloads.forEach((payload) => {
|
||||
expect(sanitizeUrl(payload)).toBe('#');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes empty string', () => {
|
||||
expect(sanitizeUrl('')).toBe('#');
|
||||
});
|
||||
|
||||
it('is case-insensitive with protocols', () => {
|
||||
expect(sanitizeUrl('JAVASCRIPT:alert(1)')).toBe('#');
|
||||
});
|
||||
|
||||
it('allows bare domain names', () => {
|
||||
expect(sanitizeUrl('example.com/page')).toBe('//example.com/page');
|
||||
});
|
||||
});
|
||||
19
packages/gazzodown/src/elements/sanitizeUrl.ts
Normal file
19
packages/gazzodown/src/elements/sanitizeUrl.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const sanitizeUrl = (href: string) => {
|
||||
if (!href) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
try {
|
||||
const hasProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href);
|
||||
|
||||
if (hasProtocol) {
|
||||
const url = new URL(href);
|
||||
const dangerousProtocols = ['javascript:', 'data:', 'vbscript:'];
|
||||
return dangerousProtocols.includes(url.protocol.toLowerCase()) ? '#' : url.href;
|
||||
}
|
||||
|
||||
return `//${href}`;
|
||||
} catch {
|
||||
return '#';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user