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
28 changes: 23 additions & 5 deletions app/(app)/articles/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import { type Metadata } from "next";
import { getPost } from "@/server/lib/posts";
import { getCamelCaseFromLower } from "@/utils/utils";
import { generateHTML } from "@tiptap/core";
import { TiptapExtensions } from "@/components/editor/editor/extensions";
import DOMPurify from "isomorphic-dompurify";
import { RenderExtensions } from "@/components/editor/editor/extensions/render-extensions";
import sanitizeHtml from "sanitize-html";
import type { JSONContent } from "@tiptap/core";
import NotFound from "@/components/NotFound/NotFound";

Expand Down Expand Up @@ -73,9 +73,27 @@ const parseJSON = (str: string): JSONContent | null => {
};

const renderSanitizedTiptapContent = (jsonContent: JSONContent) => {
const rawHtml = generateHTML(jsonContent, [...TiptapExtensions]);
// Sanitize the HTML
return DOMPurify.sanitize(rawHtml);
const rawHtml = generateHTML(jsonContent, [...RenderExtensions]);
// Sanitize the HTML using sanitize-html (server-safe, no jsdom dependency)
return sanitizeHtml(rawHtml, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
"img",
"iframe",
"h1",
"h2",
]),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ["src", "alt", "title", "width", "height", "class"],
iframe: ["src", "width", "height", "frameborder", "allowfullscreen"],
"*": ["class", "id", "style"],
},
allowedIframeHostnames: [
"www.youtube.com",
"youtube.com",
"www.youtube-nocookie.com",
],
});
};

const ArticlePage = async (props: Props) => {
Expand Down
119 changes: 119 additions & 0 deletions components/editor/editor/extensions/render-extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Server-safe extensions for generateHTML() - no React/DOM dependencies
* Use this for server-side rendering of Tiptap content (e.g., article pages)
* For the editor, use TiptapExtensions from index.tsx instead
*/
import StarterKit from "@tiptap/starter-kit";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import TiptapLink from "@tiptap/extension-link";
import Link from "@tiptap/extension-link";
import TextStyle from "@tiptap/extension-text-style";
import { Markdown } from "tiptap-markdown";
import { InputRule } from "@tiptap/core";
import UpdatedImage from "./updated-image";
import Document from "@tiptap/extension-document";
import Paragraph from "@tiptap/extension-paragraph";
import Text from "@tiptap/extension-text";
import Youtube from "@tiptap/extension-youtube";

const CustomDocument = Document.extend({
content: "heading block*",
});

export const RenderExtensions = [
CustomDocument,
Paragraph,
Text,
StarterKit.configure({
document: false,
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-stone-700",
},
},
codeBlock: {
HTMLAttributes: {
class:
"rounded-sm bg-stone-100 p-5 font-mono font-medium text-stone-800",
},
},
code: {
HTMLAttributes: {
class:
"rounded-md bg-stone-200 px-1.5 py-1 font-mono font-medium text-stone-900",
spellcheck: "false",
},
},
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
width: 4,
},
gapcursor: false,
}),
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range }) => {
const { tr } = state;
const start = range.from;
const end = range.to;

tr.insert(start - 1, this.type.create({})).delete(
tr.mapping.map(start),
tr.mapping.map(end),
);
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mt-4 mb-6 border-t border-stone-300",
},
}),
TiptapLink.configure({
HTMLAttributes: {
class:
"text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer",
},
}),
UpdatedImage.configure({
HTMLAttributes: {
class: "rounded-lg border border-stone-200",
},
}),
TextStyle,
Link.configure({
HTMLAttributes: {
class:
"text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer",
},
}),
Markdown.configure({
html: false,
transformCopiedText: true,
}),
Youtube.configure({
width: 480,
height: 320,
allowFullscreen: true,
}),
];
2 changes: 0 additions & 2 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ const REMOTE_PATTERNS = [
}));

const config = {
// Exclude jsdom and isomorphic-dompurify from bundling to fix ESM/CJS compatibility
serverExternalPackages: ["jsdom", "isomorphic-dompurify"],
// Turbopack configuration for SVGR (replaces webpack config)
turbopack: {
rules: {
Expand Down
Loading
Loading