Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 38 additions & 34 deletions app/dashboard/diagrams/text/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,33 +22,6 @@ interface Diagram {
category?: string
}

function sanitizeMermaidSvg(svg: string) {
const parser = new DOMParser()
const doc = parser.parseFromString(svg, "image/svg+xml")
const disallowedTags = new Set(["script", "foreignObject"])
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT)
const toRemove: Element[] = []

while (walker.nextNode()) {
const element = walker.currentNode as Element
if (disallowedTags.has(element.tagName)) {
toRemove.push(element)
continue
}

for (const attr of Array.from(element.attributes)) {
const name = attr.name.toLowerCase()
const value = attr.value.trim().toLowerCase()
if (name.startsWith("on") || value.startsWith("javascript:")) {
element.removeAttribute(attr.name)
}
}
}

toRemove.forEach((element) => element.remove())
return doc.documentElement
}

function toViewDiagram(row: DBDiagram): Diagram {
const payload = row.data && typeof row.data === "object" ? row.data : {}
return {
Expand All @@ -75,9 +48,27 @@ export default function TextDiagramEditorPage() {
const [isPreviewMode, setIsPreviewMode] = useState(false)
const [savedMessage, setSavedMessage] = useState(false)
const [error, setError] = useState<string | null>(null)
const previewRef = useRef<HTMLDivElement>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const previewUrlRef = useRef<string | null>(null)
const [showDocumentation, setShowDocumentation] = useState(false)

const replacePreviewUrl = useCallback((url: string | null) => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current)
}

previewUrlRef.current = url
setPreviewUrl(url)
}, [])

useEffect(() => {
return () => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current)
}
}
}, [])

useEffect(() => {
const initMermaid = async () => {
const { default: mermaid } = await import("mermaid")
Expand Down Expand Up @@ -132,24 +123,25 @@ export default function TextDiagramEditorPage() {
}, [diagramId, router])

const renderDiagram = useCallback(async () => {
if (!previewRef.current || !textContent) return
if (!textContent) return

try {
setError(null)
previewRef.current.replaceChildren()

const { default: mermaid } = await import("mermaid")
const id = `mermaid-${Date.now()}`
const { svg } = await mermaid.render(id, textContent)
previewRef.current.appendChild(sanitizeMermaidSvg(svg))
const blob = new Blob([svg], { type: "image/svg+xml" })
replacePreviewUrl(URL.createObjectURL(blob))
} catch (err: any) {
replacePreviewUrl(null)
setError(err.message || "Failed to render diagram")
console.error("Mermaid render error:", err)
}
}, [textContent])
}, [replacePreviewUrl, textContent])

useEffect(() => {
if (isPreviewMode && textContent && previewRef.current) {
if (isPreviewMode && textContent) {
void renderDiagram()
}
}, [isPreviewMode, textContent, renderDiagram])
Expand Down Expand Up @@ -409,7 +401,19 @@ export default function TextDiagramEditorPage() {
<pre className="text-sm whitespace-pre-wrap">{error}</pre>
</div>
) : (
<div ref={previewRef} className="flex items-center justify-center min-h-full" />
<div className="flex min-h-full items-center justify-center">
{previewUrl ? (
// Mermaid preview uses a generated object URL, so Next Image optimization does not apply.
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewUrl}
alt={diagram?.name ? `${diagram.name} preview` : "Diagram preview"}
className="max-h-full max-w-full object-contain"
/>
) : (
<p className="text-sm text-muted-foreground">Render the diagram to preview it.</p>
)}
</div>
)}
</div>
</div>
Expand Down
35 changes: 21 additions & 14 deletions lib/config/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1763,10 +1763,19 @@ export function getTranslations(lang: Language): Translations {

const clone = JSON.parse(JSON.stringify(base)) as Translations

const merge = (target: Record<string, any>, source: Record<string, any>) => {
const blockedKeys = new Set(["__proto__", "constructor", "prototype"])
const isUnsafeMergeKey = (key: string) =>
key === "__proto__" || key === "constructor" || key === "prototype"

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)

const merge = (target: Record<string, unknown>, source: Record<string, unknown>) => {
Object.keys(source).forEach((key) => {
if (blockedKeys.has(key)) {
if (
isUnsafeMergeKey(key) ||
!Object.prototype.hasOwnProperty.call(source, key) ||
!Object.prototype.hasOwnProperty.call(target, key)
) {
return
}

Expand All @@ -1776,22 +1785,20 @@ export function getTranslations(lang: Language): Translations {
}

const targetValue = target[key]
if (
typeof targetValue === "object" &&
targetValue !== null &&
!Array.isArray(targetValue) &&
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
merge(targetValue, value as Record<string, any>)
if (isRecord(targetValue) && isRecord(value)) {
merge(targetValue, value)
} else {
target[key] = value
Object.defineProperty(target, key, {
value,
configurable: true,
enumerable: true,
writable: true,
})
}
})
}

merge(clone as unknown as Record<string, any>, overrides as Record<string, any>)
merge(clone as unknown as Record<string, unknown>, overrides as Record<string, unknown>)
return clone
}

Expand Down
Loading