Skip to content

Instantly share code, notes, and snippets.

@AdventureBear
Created August 4, 2024 04:13
Show Gist options
  • Save AdventureBear/7ddefdaf00785186095e9ed2898375e6 to your computer and use it in GitHub Desktop.
Save AdventureBear/7ddefdaf00785186095e9ed2898375e6 to your computer and use it in GitHub Desktop.
React / NextJS Readme.md Component Viewer
"use client";
import React, {useState} from 'react';
import ReactMarkdown from 'react-markdown';
import "./CollapsibleReadme.css"
import { Flex, IconButton} from "@radix-ui/themes";
import {MoonIcon, SunIcon} from "@radix-ui/react-icons";
interface CollapsibleReadmeProps {
readmeContent: string;
}
interface Section {
title: string;
content: string;
subSections?: Section[];
}
const CollapsibleReadme: React.FC<CollapsibleReadmeProps> = ({ readmeContent }) => {
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
const toggleTheme = () => {
setTheme((prevTheme) => (prevTheme === 'dark' ? 'light' : 'dark'));
};
const parseReadmeToSections = (content: string): Section[] => {
const lines = content.split('\n');
const sections: Section[] = [];
const sectionStack: Section[] = [];
const headingRegex = /^(#{2,6})\s+(.*)$/; // Matches ## to ###### followed by the title
lines.forEach((line) => {
const trimmedLine = line.trim();
const headingMatch = trimmedLine.match(headingRegex);
if (headingMatch) {
const level = headingMatch[1].length; // Number of # characters
const title = headingMatch[2];
const newSection: Section = { title: headingMatch[1] + ' ' + title, content: '', subSections: [] };
// Pop sections from the stack until we find the correct parent level
while (
sectionStack.length > 0 &&
sectionStack[sectionStack.length - 1].title.match(/^#{2,}/)?.[0]?.length! >= level
) {
sectionStack.pop();
}
if (sectionStack.length === 0) {
sections.push(newSection);
} else {
const parentSection = sectionStack[sectionStack.length - 1];
parentSection.subSections!.push(newSection);
}
sectionStack.push(newSection);
} else if (sectionStack.length > 0) {
sectionStack[sectionStack.length - 1].content += `${trimmedLine}\n`;
}
});
return sections;
};
const renderSections = (sections: Section[], level: number = 2) => {
return sections.map((section, index) => (
<div key={index} style={{ marginLeft: `${(level - 2) * 20}px` }}>
<button className="collapsible" onClick={toggleCollapsible}>
<ReactMarkdown>{section.title}</ReactMarkdown>
</button>
<div className="content">
<ReactMarkdown>{section.content.trim()}</ReactMarkdown>
{section.subSections && renderSections(section.subSections, level + 1)}
</div>
</div>
));
};
const toggleCollapsible = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
const button = event.currentTarget;
button.classList.toggle('active');
const content = button.nextElementSibling as HTMLElement;
if (content.style.display === "block") {
content.style.display = "none";
} else {
content.style.display = "block";
}
};
const sections = parseReadmeToSections(readmeContent);
if (!sections.length) {
return <p>No content available to display.</p>;
}
return (
<div className={`${theme}-theme w-[800px]`}>
<Flex className="pb-4 pr-4" justify="between">
<span className="font-bold text-xl"> Developer Documentation</span>
<IconButton
variant="ghost"
onClick={toggleTheme}
color="blue"
size="4"
>
{theme === 'dark' ? <SunIcon width="20" height="20"/> : <MoonIcon width="20" height="20" />}
</IconButton>
</Flex>
{renderSections(sections)}
</div>
);
};
export default CollapsibleReadme;
/* Base Styles for Both Themes */
body {
margin: 0;
padding: 0;
font-family: 'Courier New', Courier, monospace;
}
/* Collapsible Button Styles */
.collapsible {
background-color: #111; /* Dark background */
color: #00ff00; /* Bright green text */
cursor: pointer;
padding: 10px;
width: 100%;
border: none;
text-align: left;
outline: none;
font-size: 15px;
margin-bottom: 5px;
}
.collapsible:hover {
background-color: #333; /* Slightly lighter on hover */
}
/* Active Collapsible */
.active, .collapsible:focus {
background-color: #333;
}
/* Content Area */
.content {
padding: 0 18px;
display: none;
overflow: hidden;
background-color: #111;
color: #00ff00; /* Bright green text */
border-left: 2px solid #00ff00;
}
/* Light Theme Overrides */
.light-theme .collapsible {
background-color: #f9f9f9;
color: #333;
}
.light-theme .collapsible:hover,
.light-theme .active,
.light-theme .collapsible:focus {
background-color: #ddd;
}
.light-theme .content {
background-color: #f9f9f9;
color: #333;
border-left: 2px solid #333;
}
/* Global Backgrounds */
.dark-theme body {
background-color: #000;
color: #00ff00;
}
.light-theme body {
background-color: #fff;
color: #000;
}
import fs from 'fs';
import path from 'path';
import React, {useMemo} from 'react';
import CollapsibleReadme from './CollapsibleReadme';
async function getReadme() {
const filePath = path.join(process.cwd(), 'README.md');
return fs.readFileSync(filePath, 'utf8');
}
const ReadMePage = async () => {
const readmeContent = await useMemo(()=>{
return getReadme()
}, [])
return (
<CollapsibleReadme readmeContent={readmeContent}/>
);
};
export default ReadMePage;
@AdventureBear
Copy link
Author

You need to install npm install react-markdown
My toggle uses Icons from Radix UI, but you can use the same conditional logic and display text or any other button you like.
Update the location of your README.md file if it is not located in the root

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment