diff --git a/package.json b/package.json index 9cd9c657..72d222fc 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "argon2": "^0.44.0", "concurrently": "^9.2.1", "discord.js": "^14.26.2", + "dompurify": "^3.3.3", "dotenv-cli": "^11.0.0", "drizzle-orm": "^0.45.2", "fast-xml-parser": "^5.5.9", @@ -70,8 +71,6 @@ "html-rewriter-wasm": "^0.4.1", "htmlparser2": "^10.1.0", "ioredis": "^5.9.3", - "isomorphic-dompurify": "^3.7.1", - "jsdom": "^29.0.1", "linkedom": "^0.18.12", "mammoth": "^1.11.0", "marked": "^17.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d1a7341..52cf07b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: discord.js: specifier: ^14.26.2 version: 14.26.2 + dompurify: + specifier: ^3.3.3 + version: 3.3.3 dotenv-cli: specifier: ^11.0.0 version: 11.0.0 @@ -87,12 +90,6 @@ importers: ioredis: specifier: ^5.9.3 version: 5.9.3 - isomorphic-dompurify: - specifier: ^3.7.1 - version: 3.7.1 - jsdom: - specifier: ^29.0.1 - version: 29.0.2 linkedom: specifier: ^0.18.12 version: 0.18.12 @@ -4554,10 +4551,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isomorphic-dompurify@3.7.1: - resolution: {integrity: sha512-ChhzwwCm7k8h8ANiq1Vc7geCWeHGaAPusgXU5N4mu7Y2wChgn2JHvbUe6aH/XQOUG3+KV+GmqSq95MntW/V1ng==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} - iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -6429,6 +6422,7 @@ snapshots: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 + optional: true '@asamuzakjp/dom-selector@7.0.8': dependencies: @@ -6436,8 +6430,10 @@ snapshots: bidi-js: 1.0.3 css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 + optional: true - '@asamuzakjp/nwsapi@2.3.9': {} + '@asamuzakjp/nwsapi@2.3.9': + optional: true '@aws-crypto/crc32@5.2.0': dependencies: @@ -7542,13 +7538,16 @@ snapshots: '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 + optional: true - '@csstools/color-helpers@6.0.2': {} + '@csstools/color-helpers@6.0.2': + optional: true '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 + optional: true '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: @@ -7556,16 +7555,20 @@ snapshots: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 + optional: true '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-tokenizer': 4.0.0 + optional: true '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 + optional: true - '@csstools/css-tokenizer@4.0.0': {} + '@csstools/css-tokenizer@4.0.0': + optional: true '@discordjs/builders@1.14.1': dependencies: @@ -7772,7 +7775,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@exodus/bytes@1.15.0': {} + '@exodus/bytes@1.15.0': + optional: true '@hapi/bourne@3.0.0': {} @@ -9879,6 +9883,7 @@ snapshots: bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + optional: true bignumber.js@9.3.1: {} @@ -10093,6 +10098,7 @@ snapshots: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 + optional: true css-what@6.2.2: {} @@ -10114,6 +10120,7 @@ snapshots: whatwg-url: 16.0.1 transitivePeerDependencies: - '@noble/hashes' + optional: true data-view-buffer@1.0.2: dependencies: @@ -10141,7 +10148,8 @@ snapshots: dependencies: ms: 2.1.3 - decimal.js@10.6.0: {} + decimal.js@10.6.0: + optional: true deep-is@0.1.4: {} @@ -10291,7 +10299,8 @@ snapshots: entities@4.5.0: {} - entities@6.0.1: {} + entities@6.0.1: + optional: true entities@7.0.1: {} @@ -11049,6 +11058,7 @@ snapshots: '@exodus/bytes': 1.15.0 transitivePeerDependencies: - '@noble/hashes' + optional: true html-entities@2.6.0: {} @@ -11245,7 +11255,8 @@ snapshots: is-obj@1.0.1: {} - is-potential-custom-element-name@1.0.1: {} + is-potential-custom-element-name@1.0.1: + optional: true is-promise@4.0.0: {} @@ -11304,14 +11315,6 @@ snapshots: isexe@2.0.0: {} - isomorphic-dompurify@3.7.1: - dependencies: - dompurify: 3.3.3 - jsdom: 29.0.2 - transitivePeerDependencies: - - '@noble/hashes' - - canvas - iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -11374,6 +11377,7 @@ snapshots: xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' + optional: true jsesc@3.1.0: {} @@ -11595,7 +11599,8 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.3.2: {} + lru-cache@11.3.2: + optional: true lru-cache@5.1.1: dependencies: @@ -11634,7 +11639,8 @@ snapshots: math-intrinsics@1.1.0: {} - mdn-data@2.27.1: {} + mdn-data@2.27.1: + optional: true media-typer@0.3.0: {} @@ -11892,6 +11898,7 @@ snapshots: parse5@8.0.0: dependencies: entities: 6.0.1 + optional: true parseurl@1.3.3: {} @@ -12287,6 +12294,7 @@ snapshots: saxes@6.0.0: dependencies: xmlchars: 2.2.0 + optional: true scheduler@0.27.0: {} @@ -12621,7 +12629,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - symbol-tree@3.2.4: {} + symbol-tree@3.2.4: + optional: true tailwindcss@4.1.18: {} @@ -12677,11 +12686,13 @@ snapshots: tinyrainbow@3.0.3: {} - tldts-core@7.0.23: {} + tldts-core@7.0.23: + optional: true tldts@7.0.23: dependencies: tldts-core: 7.0.23 + optional: true to-regex-range@5.0.1: dependencies: @@ -12692,6 +12703,7 @@ snapshots: tough-cookie@6.0.1: dependencies: tldts: 7.0.23 + optional: true tr46@0.0.3: {} @@ -12702,6 +12714,7 @@ snapshots: tr46@6.0.0: dependencies: punycode: 2.3.1 + optional: true tree-kill@1.2.2: {} @@ -12826,7 +12839,8 @@ snapshots: undici@6.24.1: {} - undici@7.24.7: {} + undici@7.24.7: + optional: true unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -12955,6 +12969,7 @@ snapshots: w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 + optional: true walk-up-path@4.0.0: {} @@ -12971,7 +12986,8 @@ snapshots: webidl-conversions@4.0.2: {} - webidl-conversions@8.0.1: {} + webidl-conversions@8.0.1: + optional: true webpack-sources@1.4.3: dependencies: @@ -13014,7 +13030,8 @@ snapshots: - esbuild - uglify-js - whatwg-mimetype@5.0.0: {} + whatwg-mimetype@5.0.0: + optional: true whatwg-url@16.0.1: dependencies: @@ -13023,6 +13040,7 @@ snapshots: webidl-conversions: 8.0.1 transitivePeerDependencies: - '@noble/hashes' + optional: true whatwg-url@5.0.0: dependencies: @@ -13277,11 +13295,13 @@ snapshots: ws@8.19.0: {} - xml-name-validator@5.0.0: {} + xml-name-validator@5.0.0: + optional: true xmlbuilder@10.1.1: {} - xmlchars@2.2.0: {} + xmlchars@2.2.0: + optional: true xtend@4.0.2: {} diff --git a/src/components/entries/EntryContent.tsx b/src/components/entries/EntryContent.tsx index de4cb4b3..1b5bf2e4 100644 --- a/src/components/entries/EntryContent.tsx +++ b/src/components/entries/EntryContent.tsx @@ -11,6 +11,7 @@ "use client"; import { Suspense, useEffect, useRef, useCallback, useState } from "react"; +import dynamic from "next/dynamic"; import { trpc } from "@/lib/trpc/client"; import { toast } from "sonner"; import { useEntryMutations } from "@/lib/hooks/useEntryMutations"; @@ -18,9 +19,15 @@ import { useShowOriginalPreference } from "@/lib/hooks/useShowOriginalPreference import { ScrollContainer } from "@/components/layout/ScrollContainerContext"; import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { getDomain } from "@/lib/format"; -import { EntryContentBody } from "./EntryContentBody"; import { EntryContentFallback } from "./EntryContentFallback"; +// Dynamic import with ssr: false ensures DOMPurify (which requires a browser DOM) +// is never evaluated on the server during SSR. +const EntryContentBody = dynamic( + () => import("./EntryContentBody").then((mod) => mod.EntryContentBody), + { ssr: false } +); + /** * Props for the EntryContent component. */ diff --git a/src/components/entries/EntryContentBody.tsx b/src/components/entries/EntryContentBody.tsx index f43153a0..fc914d9d 100644 --- a/src/components/entries/EntryContentBody.tsx +++ b/src/components/entries/EntryContentBody.tsx @@ -9,28 +9,32 @@ import { useEffect, useRef, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -import DOMPurify from "isomorphic-dompurify"; +import DOMPurify from "dompurify"; // Configure DOMPurify to: // 1. Open all external links in new tabs // 2. Lazy load all images // This hook runs after each element is sanitized -DOMPurify.addHook("afterSanitizeAttributes", (node) => { - // Add target="_blank" for external links - if (node.tagName === "A" && node.hasAttribute("href")) { - const href = node.getAttribute("href") ?? ""; - // Only add target="_blank" for http/https links (external links) - if (href.startsWith("http://") || href.startsWith("https://")) { - node.setAttribute("target", "_blank"); - node.setAttribute("rel", "noopener noreferrer"); +// Guard: DOMPurify.addHook is only available when a DOM is present (browser). +// During SSR, DOMPurify returns a stub with isSupported=false and no methods. +if (DOMPurify.isSupported) { + DOMPurify.addHook("afterSanitizeAttributes", (node) => { + // Add target="_blank" for external links + if (node.tagName === "A" && node.hasAttribute("href")) { + const href = node.getAttribute("href") ?? ""; + // Only add target="_blank" for http/https links (external links) + if (href.startsWith("http://") || href.startsWith("https://")) { + node.setAttribute("target", "_blank"); + node.setAttribute("rel", "noopener noreferrer"); + } } - } - // Lazy load all images - if (node.tagName === "IMG") { - node.setAttribute("loading", "lazy"); - } -}); + // Lazy load all images + if (node.tagName === "IMG") { + node.setAttribute("loading", "lazy"); + } + }); +} import { Button } from "@/components/ui/button"; import { StarIcon,