Hello World!
Fx64b
2024-10-16
Hello World! This is the first "blog post" on this site.
After procrastinating for close to two years, I finally found the time to replace the "Work in Progress" page with a proper site:
What I used to build this site
- Next.js for static and dynamic pages.
- Gray Matter & React Markdown for markdown to react conversion
- Tailwind CSS & NextUI for components and additional styling.
- Prism Themes & React Syntax Highlighter for code syntax highlighting.
- Vercel for hosting, analytics and deployment.
...
Well you might ask yourself now, "Why not use plain HTML and CSS and just build a static site?"
Great question! It's almost like asking a chef why he didn't just microwave a frozen pizza for dinner.
Sure, it's quick and gets the job done but where is the fun in that?
And nothing screams "I'm serious about web development" like having a CI/CD pipeline and automated versioning set up for a blog that I'll probably update twice a year.
So why all this?
Because I can.- Because, honestly, I really wanted to experiment with these technologies, but I don't really have any ideas for actual projects to use them on.
Building the site
The most interesting and difficult part was definitely the MarkdownRenderer.tsx
, specifically the codeblock and syntax highlighting part.
After several hours of googling and getting nonsense answers from ChatGPT I ended up with this not so clean solution:
TypeScript1// components/MarkdownRenderer.tsx 2// ... 3interface MarkdownRendererProps { 4 content: string 5} 6 7interface CodeBlockProps { 8 inline?: boolean 9 className?: string 10 children?: React.ReactNode 11 [key: string]: any 12} 13 14const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content }) => { 15 const CodeBlock: React.FC<CodeBlockProps> = ({ 16 inline, 17 className, 18 children, 19 ...props 20 }) => { 21 const [isCopied, setIsCopied] = useState(false) 22 const match = /language-(\w+)/.exec(className || '') 23 const language = match?.[1] 24 25 const code = String(children).replace(/\n$/, '') 26 27 return !inline && language ? ( 28 <div style={{ position: 'relative' }}> 29 <CopyToClipboard 30 text={code} 31 onCopy={() => { 32 setIsCopied(true) 33 setTimeout(() => setIsCopied(false), 2000) // Reset after 2 seconds 34 }} 35 > 36 <button 37 style={{ 38 position: 'absolute', 39 top: '1.1rem', 40 right: '1rem', 41 background: 'none', 42 border: 'none', 43 cursor: 'pointer', 44 color: isCopied ? '#006fee' : '#fff', 45 }} 46 aria-label="Copy code to clipboard" 47 > 48 {isCopied ? ( 49 <CheckIcon 50 style={{ width: '1.25rem', height: '1.25rem' }} 51 /> 52 ) : ( 53 <ClipboardIcon 54 style={{ width: '1.25rem', height: '1.25rem' }} 55 /> 56 )} 57 </button> 58 </CopyToClipboard> 59 <SyntaxHighlighter 60 style={oneDark} 61 language={language} 62 PreTag="div" 63 {...props} 64 > 65 {code} 66 </SyntaxHighlighter> 67 </div> 68 ) : ( 69 <span {...props}>{children}</span> 70 ) 71 } 72 73 return ( 74 <ReactMarkdown 75 rehypePlugins={[rehypeRaw]} 76 components={{ 77 code: CodeBlock as any, 78 hr: () => <Divider />, 79 a: ({ href, children }) => ( 80 <Link href={href!} isExternal> 81 {children} 82 </Link> 83 ), 84 }} 85 > 86 {content} 87 </ReactMarkdown> 88 ) 89}
Here is how it works
CodeBlock
Subcomponent
TypeScript1// components/MarkdownRenderer.tsx 2const CodeBlock: React.FC<CodeBlockProps> = ({ inline, className, children, ...props }) => { 3 const [isCopied, setIsCopied] = useState(false) 4 const match = /language-(\w+)/.exec(className || '') 5 const language = match?.[1] 6 7 const code = String(children).replace(/\n$/, ''); 8 ...
- We start off by setting the
useState()
for the copy button. - Then we extract the language. During the markdown processing a class that looks like this is added to the codeblock to later identify the correct language:
language-tsx
- Then the children prop (which contains all the code) is converted into a string and all trailing newline (
\n
)
The Copy button isn't too interesting, I just use the react-copy-to-clipboard
package.
Syntax highlighting is done by the <SyntaxHighlighter>
component from react-syntax-highlighter
with this relatively simple code block:
TypeScript1// components/MarkdownRenderer.tsx 2<SyntaxHighlighter style={oneDark} language={language} PreTag="div" {...props}> 3 {code} 4</SyntaxHighlighter>
If the code is inline (for example an npm install
command) we just use a basic span element which results in a clean look:
TypeScript1// components/MarkdownRenderer.tsx 2) : ( 3 <span {...props}>{children}</span> 4)
and looks like this:
Bashpnpm install is-odd@latest
- Hot-gluing it all together
Now we take these two components and patch them together like this:
TypeScript1// components/MarkdownRenderer.tsx 2return ( 3 <ReactMarkdown 4 rehypePlugins={[rehypeRaw]} 5 components={{ 6 code: CodeBlock as any, 7 hr: () => <Divider />, 8 a: ({ href, children }) => ( 9 <Link href={href!} isExternal> 10 {children} 11 </Link> 12 ), 13 }} 14 > 15 {content} 16 </ReactMarkdown> 17)
rehypeRaw
is used that things like<br>
tags are not rendered as text so that the content renders halfway decently.- In the
components
attribute I specified the html elements I want to override with my own elements, specifically theCodeBlock
component
Handling of Markdown files
Posts are stored as simple markdown files in the /content
directory.
The filename also acts as the slug in the url /content/hello-world.md
-> /blog/hello-world
Here is how postdata and content is fetched:
TS1// app/lib/posts.ts 2export function getPostBySlug(slug: string): Post | null { 3 const realSlug = slug.replace(/\.md$/, '') 4 const fullPath = path.join(postsDirectory, `${realSlug}.md`) 5 6 if (!fs.existsSync(fullPath)) { 7 return null 8 } 9 10 const fileContents = fs.readFileSync(fullPath, 'utf8') 11 const { data } = matter(fileContents) 12 13 return { 14 ...(data as Post), 15 slug: realSlug, 16 } 17}
This function additionally has a replace for .md
in case the function is called by a function that gets all filenames from the content directory like this:
TS1// app/lib/posts.ts 2export function getPostSlugs(): string[] { 3 return fs.readdirSync(postsDirectory).filter((file) => file.endsWith('.md')) 4}
and then:
TS1// app/lib/posts.ts 2export function getAllPosts(): Post[] { 3 const slugs = getPostSlugs() 4 return slugs 5 .map((slug) => getPostBySlug(slug)) 6 .filter((post): post is Post => post !== null) 7 .sort((a, b) => (a.date > b.date ? -1 : 1)) 8}
Conclusion
There is much more code I could cover here, but I'm not gonna do that. Check out the code yourself if you want to: Fx64b/fx64b.dev.
If you have some spare time to waste you can help me clean up the code by creating a pull request.
Feel free to copy the code from this site. If you for some reason decide to copy content from my blog posts and publish them yourself, please add a reference to the original post.