- {item.content || ''}
+
{item.extra ? (
- {item.extra}
+
) : null}
diff --git a/web/default/src/components/rich-content.tsx b/web/default/src/components/rich-content.tsx
new file mode 100644
index 00000000000..27299ecee7f
--- /dev/null
+++ b/web/default/src/components/rich-content.tsx
@@ -0,0 +1,39 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see
.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { isLikelyHtml } from '@/lib/content-format'
+import { HtmlContent } from '@/components/html-content'
+import { Markdown } from '@/components/ui/markdown'
+
+interface RichContentProps {
+ content: string
+ breaks?: boolean
+ className?: string
+}
+
+export function RichContent(props: RichContentProps) {
+ if (isLikelyHtml(props.content)) {
+ return
+ }
+
+ return (
+
+ {props.content}
+
+ )
+}
diff --git a/web/default/src/components/ui/markdown.tsx b/web/default/src/components/ui/markdown.tsx
index 0739b57e07b..30df268e872 100644
--- a/web/default/src/components/ui/markdown.tsx
+++ b/web/default/src/components/ui/markdown.tsx
@@ -24,6 +24,7 @@ import { useMemo } from 'react'
import { cn } from '@/lib/utils'
interface MarkdownProps {
+ breaks?: boolean
children: string
className?: string
}
@@ -186,13 +187,13 @@ function renderMath(source: string, displayMode: boolean): string {
}
function replaceEmojiShortcodes(value: string): string {
- return value.replace(/:(?:smiley|star|fa-star|fa-gear):/g, (shortcode) => {
+ return value.replaceAll(/:(?:smiley|star|fa-star|fa-gear):/g, (shortcode) => {
return emojiShortcodes[shortcode] ?? shortcode
})
}
function getTextUnits(value: string): number {
- return Array.from(value).reduce((total, character) => {
+ return [...value].reduce((total, character) => {
if (/\s/.test(character)) {
return total + 0.5
}
@@ -372,7 +373,7 @@ function renderFlowDiagram(source: string): string {
const nodePositions = new Map(
nodes.map((node, index) => [node.id, getFlowNodeLayout(node, index, centerX)])
)
- const lastNode = nodes.length > 0 ? nodePositions.get(nodes[nodes.length - 1].id) : undefined
+ const lastNode = nodes.length > 0 ? nodePositions.get(nodes.at(-1)?.id ?? '') : undefined
const height = Math.max(180, (lastNode?.y ?? 64) + (lastNode?.height ?? 40) / 2 + 54)
const renderedEdges = edges
.map((edge) => {
@@ -694,31 +695,39 @@ function addExternalLinkAttributes(html: string): string {
return template.innerHTML
}
-function renderMarkdown(markdown: string): string {
- const parsedHtml = markdownParser.parse(markdown, markdownOptions)
+function renderMarkdown(markdown: string, breaks = false): string {
+ const parsedHtml = markdownParser.parse(markdown, {
+ ...markdownOptions,
+ breaks,
+ })
const html = DOMPurify.sanitize(parsedHtml, sanitizeOptions)
return addExternalLinkAttributes(html)
}
export function Markdown(props: MarkdownProps) {
- const html = useMemo(() => renderMarkdown(props.children), [props.children])
+ const html = useMemo(
+ () => renderMarkdown(props.children, props.breaks),
+ [props.breaks, props.children]
+ )
return (
*:first-child]:mt-0 [&>*:last-child]:mb-0',
- '[overflow-wrap:anywhere] break-words',
+ '[overflow-wrap:anywhere]',
props.className
)}
dangerouslySetInnerHTML={{ __html: html }}
diff --git a/web/default/src/features/about/index.tsx b/web/default/src/features/about/index.tsx
index 9b76172364a..6898a03ba23 100644
--- a/web/default/src/features/about/index.tsx
+++ b/web/default/src/features/about/index.tsx
@@ -19,24 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
import { useQuery } from '@tanstack/react-query'
import { Construction } from 'lucide-react'
import { useTranslation } from 'react-i18next'
-import { Markdown } from '@/components/ui/markdown'
+import { RichContent } from '@/components/rich-content'
import { Skeleton } from '@/components/ui/skeleton'
import { PublicLayout } from '@/components/layout'
+import { isHttpUrl } from '@/lib/content-format'
import { getAboutContent } from './api'
-function isValidUrl(value: string) {
- try {
- const url = new URL(value)
- return url.protocol === 'http:' || url.protocol === 'https:'
- } catch {
- return false
- }
-}
-
-function isLikelyHtml(value: string) {
- return /<\/?[a-z][\s\S]*>/i.test(value)
-}
-
function EmptyAboutState() {
const { t } = useTranslation()
const currentYear = new Date().getFullYear()
@@ -131,8 +119,7 @@ export function About() {
const rawContent = data?.data?.trim() ?? ''
const hasContent = rawContent.length > 0
- const isUrl = hasContent && isValidUrl(rawContent)
- const isHtml = hasContent && !isUrl && isLikelyHtml(rawContent)
+ const isUrl = hasContent && isHttpUrl(rawContent)
if (isLoading) {
return (
@@ -162,6 +149,7 @@ export function About() {
src={rawContent}
className='h-[calc(100vh-3.5rem)] w-full border-0'
title={t('About')}
+ sandbox='allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts'
/>
)
@@ -170,16 +158,10 @@ export function About() {
return (
- {isHtml ? (
-
- ) : (
-
- {rawContent}
-
- )}
+
)
diff --git a/web/default/src/features/dashboard/components/overview/announcement-detail-dialog.tsx b/web/default/src/features/dashboard/components/overview/announcement-detail-dialog.tsx
index 5561c7e36aa..76d31005a73 100644
--- a/web/default/src/features/dashboard/components/overview/announcement-detail-dialog.tsx
+++ b/web/default/src/features/dashboard/components/overview/announcement-detail-dialog.tsx
@@ -17,8 +17,8 @@ along with this program. If not, see
.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
+import { RichContent } from '@/components/rich-content'
import { formatDateTimeObject } from '@/lib/time'
-import { Markdown } from '@/components/ui/markdown'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
@@ -59,7 +59,7 @@ export function AnnouncementDetailModal({
{announcement?.content && (
{t('Content')}
- {announcement.content}
+
)}
{announcement?.extra && (
@@ -67,9 +67,11 @@ export function AnnouncementDetailModal({
{t('Additional Information')}
-
- {announcement.extra}
-
+
)}
diff --git a/web/default/src/features/home/hooks/use-home-page-content.ts b/web/default/src/features/home/hooks/use-home-page-content.ts
index fb40c31fbd0..4ff5a1f5190 100644
--- a/web/default/src/features/home/hooks/use-home-page-content.ts
+++ b/web/default/src/features/home/hooks/use-home-page-content.ts
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { useEffect, useState } from 'react'
import i18next from 'i18next'
import { toast } from 'sonner'
+import { isHttpUrl } from '@/lib/content-format'
import { getHomePageContent } from '../api'
import type { HomePageContentResult } from '../types'
@@ -75,13 +76,7 @@ export function useHomePageContent(): HomePageContentResult {
}
}, [])
- let isUrl = false
- try {
- const url = new URL(content)
- isUrl = url.protocol === 'http:' || url.protocol === 'https:'
- } catch {
- // not a URL
- }
+ const isUrl = isHttpUrl(content)
return { content, isLoaded, isUrl }
}
diff --git a/web/default/src/features/home/index.tsx b/web/default/src/features/home/index.tsx
index 2c7a8f3dd77..103864d1048 100644
--- a/web/default/src/features/home/index.tsx
+++ b/web/default/src/features/home/index.tsx
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
-import { Markdown } from '@/components/ui/markdown'
+import { RichContent } from '@/components/rich-content'
import { PublicLayout } from '@/components/layout'
import { Footer } from '@/components/layout/components/footer'
import { CTA, Features, Hero, HowItWorks, Stats } from './components'
@@ -41,21 +41,24 @@ export function Home() {
}
if (content) {
+ if (isUrl) {
+ return (
+