Add central Markdown component with spoiler support

This commit is contained in:
Jared Schoeny 2025-12-27 16:10:11 -10:00
parent 23297cb4d9
commit ace34c4408
13 changed files with 186 additions and 86 deletions

1
package-lock.json generated
View File

@ -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": {

View File

@ -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": {

View File

@ -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() {
<div className="mx-auto max-w-screen-lg px-6 py-6 sm:py-12">
<h1 className="text-3xl font-bold">FAQ for Hackdex</h1>
<div className="mt-6 prose prose-invert max-w-none faq-prose">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{entriesMd}</ReactMarkdown>
<Markdown>{entriesMd}</Markdown>
</div>
</div>
);

View File

@ -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) {
</div>
</div>
<div className="prose prose-sm max-w-none text-foreground/80">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug]}
components={{
h1: 'h2',
h2: 'h3',
h3: 'h4',
h4: 'h5',
h5: 'h6',
h6: 'h6',
}}
>
{patch.changelog || ""}
</ReactMarkdown>
<Markdown headingLevelOffset={1}>{patch.changelog || ""}</Markdown>
</div>
</div>
))}

View File

@ -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 ? (
<div className="prose prose-sm max-w-none text-foreground/80">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug]}
components={{
h1: 'h2',
h2: 'h3',
h3: 'h4',
h4: 'h5',
h5: 'h6',
h6: 'h6',
}}
>
{patchChangelog}
</ReactMarkdown>
<Markdown headingLevelOffset={1}>{patchChangelog}</Markdown>
</div>
) : (
<p className="italic text-foreground/60">No changelog provided</p>
@ -400,20 +385,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
<div className="card-simple p-5">
<h2 className="text-2xl font-semibold tracking-tight">About this hack</h2>
<div className="prose prose-sm mt-3 max-w-none text-foreground/80">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug]}
components={{
h1: 'h2',
h2: 'h3',
h3: 'h4',
h4: 'h5',
h5: 'h6',
h6: 'h6',
}}
>
{hack.description}
</ReactMarkdown>
<Markdown headingLevelOffset={1}>{hack.description}</Markdown>
</div>
</div>
</div>

View File

@ -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 (
<div className="mx-auto max-w-screen-lg px-6 py-6 sm:py-12">
<div className="prose prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{privacyMd}</ReactMarkdown>
<Markdown>{privacyMd}</Markdown>
</div>
</div>
);

View File

@ -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 (
<div className="mx-auto max-w-screen-lg px-6 py-6 sm:py-12">
<div className="prose prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{termsMd}</ReactMarkdown>
<Markdown>{termsMd}</Markdown>
</div>
</div>
);

View File

@ -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) {
/>
) : (
<div className={`prose max-w-none rounded-md min-h-[14rem] px-3 py-2 ring-1 ring-inset ${descriptionChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'} ${description ? "" : "text-foreground/60 text-sm"}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{description || "Nothing to preview yet."}</ReactMarkdown>
<Markdown headingLevelOffset={1}>{description || "Nothing to preview yet."}</Markdown>
</div>
)}
</div>

View File

@ -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({
</div>
{isDummy ? (
<div className="prose max-w-none h-36 rounded-md bg-[var(--surface-2)] px-3 py-2 ring-1 ring-inset ring-[var(--border)] text-foreground/60 select-none">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{description || "Write a longer markdown description here."}</ReactMarkdown>
<Markdown headingLevelOffset={1}>{description || "Write a longer markdown description here."}</Markdown>
</div>
) : !showMdPreview ? (
<textarea
@ -912,7 +910,7 @@ export default function HackSubmitForm({
/>
) : (
<div className={`prose max-w-none rounded-md bg-[var(--surface-2)] min-h-[14rem] px-3 py-2 ring-1 ring-inset ring-[var(--border)] ${description ? "" : "text-foreground/60 text-sm"}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{description || "Nothing to preview yet."}</ReactMarkdown>
<Markdown headingLevelOffset={1}>{description || "Nothing to preview yet."}</Markdown>
</div>
)}
</div>

View File

@ -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
/>
) : (
<div className="prose prose-sm max-w-none text-foreground/80">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug]}
components={{
h1: 'h2',
h2: 'h3',
h3: 'h4',
h4: 'h5',
h5: 'h6',
h6: 'h6',
}}
>
{patch.changelog || ""}
</ReactMarkdown>
<Markdown headingLevelOffset={1}>{patch.changelog || ""}</Markdown>
</div>
)}
</div>

View File

@ -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<string, string> = {};
if (headingLevelOffset > 0) {
for (let i = 1; i <= 6; i++) {
const targetLevel = Math.min(i + headingLevelOffset, 6);
headingComponents[`h${i}`] = `h${targetLevel}`;
}
}
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkSpoiler]}
rehypePlugins={[rehypeSlug]}
components={{
...headingComponents,
span: ({ node, children, className, ...props }: any) => {
// 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 <Spoiler>{children}</Spoiler>;
}
// Default span rendering
return <span className={className} {...props}>{children}</span>;
},
}}
>
{children}
</ReactMarkdown>
);
}

View File

@ -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 (
<span
onClick={() => 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}
</span>
);
}

View File

@ -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<string, string> } });
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);
});
};
}