diff --git a/package-lock.json b/package-lock.json
index 0b491f5..613b622 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -35,6 +35,7 @@
"rom-patcher-js": "github:Hackdex-App/RomPatcher.js",
"schema-dts": "^1.1.5",
"serialize-javascript": "^7.0.0",
+ "unist-util-visit": "^5.0.0",
"uuid": "^13.0.0"
},
"devDependencies": {
diff --git a/package.json b/package.json
index 4b116e0..094f569 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"rom-patcher-js": "github:Hackdex-App/RomPatcher.js",
"schema-dts": "^1.1.5",
"serialize-javascript": "^7.0.0",
+ "unist-util-visit": "^5.0.0",
"uuid": "^13.0.0"
},
"devDependencies": {
diff --git a/src/app/faq/page.tsx b/src/app/faq/page.tsx
index d5d60f6..18e2f5f 100644
--- a/src/app/faq/page.tsx
+++ b/src/app/faq/page.tsx
@@ -1,8 +1,6 @@
import React from "react";
import type { Metadata } from "next";
-import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
-import rehypeSlug from "rehype-slug";
+import Markdown from "@/components/Markdown/Markdown";
import entriesMd from "./entries.md";
export const metadata: Metadata = {
@@ -18,7 +16,7 @@ export default function FAQPage() {
FAQ for Hackdex
- {entriesMd}
+ {entriesMd}
);
diff --git a/src/app/hack/[slug]/changelog/page.tsx b/src/app/hack/[slug]/changelog/page.tsx
index 96ccc63..3009394 100644
--- a/src/app/hack/[slug]/changelog/page.tsx
+++ b/src/app/hack/[slug]/changelog/page.tsx
@@ -1,8 +1,6 @@
import { notFound } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
-import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
-import rehypeSlug from "rehype-slug";
+import Markdown from "@/components/Markdown/Markdown";
import Link from "next/link";
import { FaChevronLeft } from "react-icons/fa6";
@@ -79,20 +77,7 @@ export default async function ChangelogPage({ params }: ChangelogPageProps) {
-
- {patch.changelog || ""}
-
+ {patch.changelog || ""}
))}
diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx
index 2dfe750..ed20e7a 100644
--- a/src/app/hack/[slug]/page.tsx
+++ b/src/app/hack/[slug]/page.tsx
@@ -4,9 +4,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import Gallery from "@/components/Hack/Gallery";
import HackActions from "@/components/Hack/HackActions";
-import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
-import rehypeSlug from "rehype-slug";
+import Markdown from "@/components/Markdown/Markdown";
import Image from "next/image";
import { FaDiscord, FaTwitter, FaGithub, FaTriangleExclamation, FaArrowUpRightFromSquare } from "react-icons/fa6";
import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon";
@@ -376,20 +374,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
>
{patchChangelog && patchChangelog.trim().length > 0 ? (
-
- {patchChangelog}
-
+ {patchChangelog}
) : (
No changelog provided
@@ -400,20 +385,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
About this hack
-
- {hack.description}
-
+ {hack.description}
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
index 9254bbd..3cea576 100644
--- a/src/app/privacy/page.tsx
+++ b/src/app/privacy/page.tsx
@@ -1,8 +1,6 @@
import React from "react";
import type { Metadata } from "next";
-import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
-import rehypeSlug from "rehype-slug";
+import Markdown from "@/components/Markdown/Markdown";
import privacyMd from "@/../docs/legal/PRIVACY.md";
export const metadata: Metadata = {
@@ -16,7 +14,7 @@ export default function PrivacyPage() {
return (
- {privacyMd}
+ {privacyMd}
);
diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx
index b018829..b467a18 100644
--- a/src/app/terms/page.tsx
+++ b/src/app/terms/page.tsx
@@ -1,8 +1,6 @@
import React from "react";
import type { Metadata } from "next";
-import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
-import rehypeSlug from "rehype-slug";
+import Markdown from "@/components/Markdown/Markdown";
import termsMd from "@/../docs/legal/TERMS.md";
export const metadata: Metadata = {
@@ -16,7 +14,7 @@ export default function TermsPage() {
return (
);
diff --git a/src/components/Hack/HackEditForm.tsx b/src/components/Hack/HackEditForm.tsx
index f754b6e..66116a0 100644
--- a/src/components/Hack/HackEditForm.tsx
+++ b/src/components/Hack/HackEditForm.tsx
@@ -1,9 +1,7 @@
"use client";
import React from "react";
-import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
-import rehypeSlug from "rehype-slug";
+import Markdown from "@/components/Markdown/Markdown";
import TagSelector from "@/components/Submit/TagSelector";
import { baseRoms } from "@/data/baseRoms";
import Image from "next/image";
@@ -346,7 +344,7 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
/>
) : (
- {description || "Nothing to preview yet."}
+ {description || "Nothing to preview yet."}
)}
diff --git a/src/components/Hack/HackSubmitForm.tsx b/src/components/Hack/HackSubmitForm.tsx
index f20617a..7bc96be 100644
--- a/src/components/Hack/HackSubmitForm.tsx
+++ b/src/components/Hack/HackSubmitForm.tsx
@@ -10,9 +10,7 @@ import { presignCoverUpload } from "@/app/hack/actions";
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
-import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
-import rehypeSlug from "rehype-slug";
+import Markdown from "@/components/Markdown/Markdown";
import { RxDragHandleDots2 } from "react-icons/rx";
import { FaDiscord, FaTwitter, FaGithub } from "react-icons/fa6";
import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon";
@@ -900,7 +898,7 @@ export default function HackSubmitForm({
{isDummy ? (
- {description || "Write a longer markdown description here."}
+ {description || "Write a longer markdown description here."}
) : !showMdPreview ? (
) : (
- {description || "Nothing to preview yet."}
+ {description || "Nothing to preview yet."}
)}
diff --git a/src/components/Hack/VersionList.tsx b/src/components/Hack/VersionList.tsx
index 39e4057..77f9408 100644
--- a/src/components/Hack/VersionList.tsx
+++ b/src/components/Hack/VersionList.tsx
@@ -1,9 +1,7 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
-import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
-import rehypeSlug from "rehype-slug";
+import Markdown from "@/components/Markdown/Markdown";
import { FaChevronDown, FaChevronUp, FaStar, FaDownload, FaTrash, FaRotateLeft, FaUpload, FaCheck, FaPlus } from "react-icons/fa6";
import { FiEdit2, FiEdit, FiX } from "react-icons/fi";
import VersionActions from "@/components/Hack/VersionActions";
@@ -262,20 +260,7 @@ export default function VersionList({ patches, currentPatchId, canEdit, hackSlug
/>
) : (
-
- {patch.changelog || ""}
-
+ {patch.changelog || ""}
)}
diff --git a/src/components/Markdown/Markdown.tsx b/src/components/Markdown/Markdown.tsx
new file mode 100644
index 0000000..e2885d9
--- /dev/null
+++ b/src/components/Markdown/Markdown.tsx
@@ -0,0 +1,50 @@
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import rehypeSlug from "rehype-slug";
+import remarkSpoiler from "@/utils/markdown/remark-spoiler";
+import Spoiler from "./Spoiler";
+
+interface MarkdownProps {
+ children: string;
+ className?: string;
+ headingLevelOffset?: number;
+}
+
+export default function Markdown({
+ children,
+ headingLevelOffset = 0,
+}: MarkdownProps) {
+ // Build heading demotion components if offset is provided
+ const headingComponents: Record = {};
+ if (headingLevelOffset > 0) {
+ for (let i = 1; i <= 6; i++) {
+ const targetLevel = Math.min(i + headingLevelOffset, 6);
+ headingComponents[`h${i}`] = `h${targetLevel}`;
+ }
+ }
+
+ return (
+ {
+ // Check if this span has the markdown-spoiler class (from our remark plugin)
+ if (
+ className === "markdown-spoiler" ||
+ (Array.isArray(node?.properties?.className) &&
+ node.properties.className.includes("markdown-spoiler"))
+ ) {
+ return {children};
+ }
+ // Default span rendering
+ return {children};
+ },
+ }}
+ >
+ {children}
+
+ );
+}
+
diff --git a/src/components/Markdown/Spoiler.tsx b/src/components/Markdown/Spoiler.tsx
new file mode 100644
index 0000000..fa116b4
--- /dev/null
+++ b/src/components/Markdown/Spoiler.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { useState } from "react";
+
+interface SpoilerProps {
+ children: React.ReactNode;
+}
+
+export default function Spoiler({ children }: SpoilerProps) {
+ const [revealed, setRevealed] = useState(false);
+
+ return (
+ setRevealed(true)}
+ className={`inline rounded px-1 transition-colors duration-150 ${
+ revealed
+ ? "bg-foreground/20 text-foreground cursor-text"
+ : "bg-foreground/50 text-transparent cursor-pointer hover:bg-foreground/60"
+ }`}
+ >
+ {children}
+
+ );
+}
+
diff --git a/src/utils/markdown/remark-spoiler.ts b/src/utils/markdown/remark-spoiler.ts
new file mode 100644
index 0000000..34b33e3
--- /dev/null
+++ b/src/utils/markdown/remark-spoiler.ts
@@ -0,0 +1,91 @@
+import { visit } from "unist-util-visit";
+import type { Root, Text } from "mdast";
+
+type SpoilerNode = {
+ type: "spoiler";
+ children: Text[];
+};
+
+export default function remarkSpoiler() {
+ return (tree: Root) => {
+ // Collect all modifications first
+ const modifications: Array<{
+ parent: any;
+ index: number;
+ replacement: (Text | SpoilerNode)[];
+ }> = [];
+
+ visit(tree, (node, index, parent) => {
+ // Skip code blocks and code spans - don't process spoilers inside code
+ if (node.type === "code" || node.type === "inlineCode") {
+ return;
+ }
+
+ // Process text nodes
+ if (node.type === "text" && parent && typeof index === "number") {
+ const textNode = node as Text;
+ const text = textNode.value;
+ const spoilerRegex = /\|\|([^|]+)\|\|/g;
+
+ let match;
+ let lastIndex = 0;
+ const newNodes: (Text | SpoilerNode)[] = [];
+ let hasSpoilers = false;
+
+ while ((match = spoilerRegex.exec(text)) !== null) {
+ hasSpoilers = true;
+ // Add text before the spoiler
+ if (match.index > lastIndex) {
+ newNodes.push({
+ type: "text",
+ value: text.slice(lastIndex, match.index),
+ } as Text);
+ }
+
+ // Add the spoiler node with data to help mdast-to-hast conversion
+ // The spoiler node contains a text node as a child so the content is preserved
+ newNodes.push({
+ type: "spoiler",
+ children: [
+ {
+ type: "text",
+ value: match[1],
+ } as Text,
+ ],
+ data: {
+ hName: "span",
+ hProperties: {
+ className: "markdown-spoiler",
+ },
+ },
+ } as SpoilerNode & { data?: { hName: string; hProperties: Record } });
+
+ lastIndex = spoilerRegex.lastIndex;
+ }
+
+ // Add remaining text after the last spoiler
+ if (lastIndex < text.length) {
+ newNodes.push({
+ type: "text",
+ value: text.slice(lastIndex),
+ } as Text);
+ }
+
+ // Store modification if we found spoilers
+ if (hasSpoilers) {
+ modifications.push({
+ parent,
+ index,
+ replacement: newNodes,
+ });
+ }
+ }
+ });
+
+ // Apply modifications in reverse order to maintain indices
+ modifications.reverse().forEach(({ parent, index, replacement }) => {
+ parent.children.splice(index, 1, ...replacement);
+ });
+ };
+}
+