Skip to content

Commit 732d91f

Browse files
feat: Ensure the article appears exactly as it will when published #1102 (#1153)
* feat: Ensure the article appears exactly as it will when published * Update package-lock.json to fix dependency issues * Refactor JSON parsing to optimize performance by parsing 'post.body' only once * fix: add DOMPurify for sanitizing HTML to prevent XSS attacks * fix: align pre-publish editor design to match post-publish article appearance * refactor: remove unnecessary divs and clean up code without affecting layout
1 parent e4102dd commit 732d91f

File tree

6 files changed

+588
-72
lines changed

6 files changed

+588
-72
lines changed

app/(app)/alpha/new/[[...postIdArr]]/_client.tsx

Lines changed: 49 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
} from "@headlessui/react";
1111
import { ChevronUpIcon } from "@heroicons/react/20/solid";
1212
import Editor from "@/components/editor/editor";
13-
import RenderPost from "@/components/editor/editor/RenderPost";
1413
import useCreatePage from "@/hooks/useCreatePage";
1514
import { usePrompt } from "@/components/PromptService";
1615

@@ -214,69 +213,57 @@ const Create = () => {
214213
<div className="z-60 absolute bottom-0 left-0 right-0 top-0 bg-black opacity-25" />
215214
</div>
216215
)}
217-
<div className="bg-black">
218-
<div className="mx-auto w-full max-w-7xl flex-grow text-black lg:flex xl:px-8">
219-
{/* Left sidebar & main wrapper */}
220-
<div className="min-w-0 flex-1 xl:flex">
221-
<div className="lg:min-w-0 lg:flex-1">
222-
<div className="h-full min-h-[40rem] px-4 py-0 sm:px-6 lg:px-4 lg:py-6">
223-
{/* Start main area*/}
224-
<div className="relative h-full">
225-
<div className="bg-neutral-900 text-white shadow-xl">
226-
<div className="bg-neutral-900 px-4 py-6 sm:p-6 lg:pb-8">
227-
{!body && (
228-
<Controller
229-
name="body"
230-
control={control}
231-
render={({ field }) => (
232-
<Editor {...field} initialValue={"{}"} />
233-
)}
234-
/>
235-
)}
236-
{body && body.length > 0 && (
237-
<Controller
238-
name="body"
239-
control={control}
240-
render={({ field }) => (
241-
<Editor {...field} initialValue={body} />
242-
)}
243-
/>
244-
)}
216+
<div className="mx-auto break-words px-2 pb-4 sm:px-4 md:max-w-3xl">
217+
<div className="prose mx-auto max-w-3xl dark:prose-invert lg:prose-lg">
218+
{!body && (
219+
<Controller
220+
name="body"
221+
control={control}
222+
render={({ field }) => (
223+
<Editor {...field} initialValue={"{}"} />
224+
)}
225+
/>
226+
)}
227+
{body && body.length > 0 && (
228+
<Controller
229+
name="body"
230+
control={control}
231+
render={({ field }) => (
232+
<Editor {...field} initialValue={body} />
233+
)}
234+
/>
235+
)}
245236

246-
<div className="flex items-center justify-between">
247-
<>
248-
{saveStatus === "loading" && <p>Auto-saving...</p>}
249-
{saveStatus === "error" && savedTime && (
250-
<p className="text-xs text-red-600 lg:text-sm">
251-
{`Error saving, last saved: ${savedTime.toString()}`}
252-
</p>
253-
)}
254-
{saveStatus === "success" && savedTime && (
255-
<p className="text-xs text-neutral-400 lg:text-sm">
256-
{`Saved: ${savedTime.toString()}`}
257-
</p>
258-
)}
259-
</>
260-
<div />
261-
262-
<div className="flex">
263-
<button
264-
type="button"
265-
disabled={isDisabled}
266-
className="ml-5 inline-flex justify-center bg-gradient-to-r from-orange-400 to-pink-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:from-orange-300 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-300 focus:ring-offset-2 disabled:opacity-50"
267-
onClick={() => setOpen(true)}
268-
>
269-
{!data?.published && "Publish"}
270-
{data?.published && "Save Changes"}
271-
</button>
272-
</div>
273-
</div>
274-
</div>
275-
</div>
276-
</div>
277-
{/* End main area */}
278-
</div>
237+
<div className="flex items-center justify-between">
238+
<div>
239+
{saveStatus === "loading" && (
240+
<p className="text-xs lg:text-sm">Auto-saving...</p>
241+
)}
242+
{saveStatus === "error" && savedTime && (
243+
<p className="text-xs text-red-600 lg:text-sm">
244+
Error saving, last saved: {savedTime.toString()}
245+
</p>
246+
)}
247+
{saveStatus === "success" && savedTime && (
248+
<p className="text-xs text-neutral-400 lg:text-sm">
249+
Saved: {savedTime.toString()}
250+
</p>
251+
)}
279252
</div>
253+
254+
<button
255+
type="button"
256+
disabled={isDisabled}
257+
className="ml-5 inline-flex justify-center bg-gradient-to-r from-orange-400 to-pink-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:from-orange-300 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-300 focus:ring-offset-2 disabled:opacity-50"
258+
onClick={() => setOpen(true)}
259+
aria-label={
260+
data?.published
261+
? "Save changes to document"
262+
: "Publish document"
263+
}
264+
>
265+
{data?.published ? "Save Changes" : "Publish"}
266+
</button>
280267
</div>
281268
</div>
282269
</div>

app/(app)/articles/[slug]/page.tsx

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from "react";
2+
import type { RenderableTreeNode } from "@markdoc/markdoc";
23
import Markdoc from "@markdoc/markdoc";
34
import Link from "next/link";
45
import BioBar from "@/components/BioBar/BioBar";
@@ -13,6 +14,10 @@ import ArticleAdminPanel from "@/components/ArticleAdminPanel/ArticleAdminPanel"
1314
import { type Metadata } from "next";
1415
import { getPost } from "@/server/lib/posts";
1516
import { getCamelCaseFromLower } from "@/utils/utils";
17+
import { generateHTML } from "@tiptap/html";
18+
import { TiptapExtensions } from "@/components/editor/editor/extensions";
19+
import DOMPurify from "isomorphic-dompurify";
20+
import type { JSONContent } from "@tiptap/core";
1621

1722
type Props = { params: { slug: string } };
1823

@@ -57,6 +62,20 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
5762
};
5863
}
5964

65+
const parseJSON = (str: string): JSONContent | null => {
66+
try {
67+
return JSON.parse(str);
68+
} catch (e) {
69+
return null;
70+
}
71+
};
72+
73+
const renderSanitizedTiptapContent = (jsonContent: JSONContent) => {
74+
const rawHtml = generateHTML(jsonContent, [...TiptapExtensions]);
75+
// Sanitize the HTML
76+
return DOMPurify.sanitize(rawHtml);
77+
};
78+
6079
const ArticlePage = async ({ params }: Props) => {
6180
const session = await getServerAuthSession();
6281
const { slug } = params;
@@ -66,11 +85,24 @@ const ArticlePage = async ({ params }: Props) => {
6685
const post = await getPost({ slug });
6786

6887
if (!post) {
69-
notFound();
88+
return notFound();
7089
}
7190

72-
const ast = Markdoc.parse(post.body);
73-
const content = Markdoc.transform(ast, config);
91+
const parsedBody = parseJSON(post.body);
92+
const isTiptapContent = parsedBody?.type === "doc";
93+
94+
let renderedContent: string | RenderableTreeNode;
95+
96+
if (isTiptapContent && parsedBody) {
97+
const jsonContent = parsedBody;
98+
renderedContent = renderSanitizedTiptapContent(jsonContent);
99+
} else {
100+
const ast = Markdoc.parse(post.body);
101+
const transformedContent = Markdoc.transform(ast, config);
102+
renderedContent = Markdoc.renderers.react(transformedContent, React, {
103+
components: markdocComponents,
104+
}) as unknown as string;
105+
}
74106

75107
return (
76108
<>
@@ -83,10 +115,16 @@ const ArticlePage = async ({ params }: Props) => {
83115
/>
84116
<div className="mx-auto break-words px-2 pb-4 sm:px-4 md:max-w-3xl">
85117
<article className="prose mx-auto max-w-3xl dark:prose-invert lg:prose-lg">
86-
<h1>{post.title}</h1>
87-
{Markdoc.renderers.react(content, React, {
88-
components: markdocComponents,
89-
})}
118+
{!isTiptapContent && <h1>{post.title}</h1>}
119+
120+
{isTiptapContent ? (
121+
<div
122+
dangerouslySetInnerHTML={{ __html: renderedContent }}
123+
className="tiptap-content"
124+
/>
125+
) : (
126+
<div>{renderedContent}</div>
127+
)}
90128
</article>
91129
{post.tags.length > 0 && (
92130
<section className="flex flex-wrap gap-3">

components/editor/editor/RenderPost.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from "react";
22
import { TiptapExtensions } from "./extensions";
33
import { EditorContent, useEditor } from "@tiptap/react";
4+
import SlashCommand from "./extensions/slash-command";
45

56
interface RenderPostProps {
67
json: string;
@@ -11,7 +12,7 @@ const RenderPost = ({ json }: RenderPostProps) => {
1112

1213
const editor = useEditor({
1314
editable: false,
14-
extensions: [...TiptapExtensions],
15+
extensions: [...TiptapExtensions, SlashCommand],
1516
content,
1617
});
1718

components/editor/editor/extensions/index.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,6 @@ export const TiptapExtensions = [
138138
return "type / to see a list of formatting features";
139139
},
140140
}),
141-
SlashCommand,
142141
TextStyle,
143142
Link.configure({
144143
HTMLAttributes: {

0 commit comments

Comments
 (0)