Ben C's BlogI write here sometimeshttps://bwc9876.dev/en-usSteam Remote Play Headlesshttps://bwc9876.dev/blog/posts/headless_steam_remote_play/https://bwc9876.dev/blog/posts/headless_steam_remote_play/How I'm using gamescope headless to run Steam remote play.Sat, 17 Jan 2026 00:00:00 GMT<p>For a while, I've wanted a setup where I can simply SSH into my desktop, start Steam, and use remote play to play my games.</p> <p>Gamescope offers a backend called <code>headless</code> that allows me to do this, after some messing around I got it working mostly.</p> <p>There's other software like <a href="https://github.com/LizardByte/Sunshine">Sunshine</a> that seem cool and have NixOS modules. I chose to avoid these because of the extra setup they need, and my use-case is a lot simpler than what they're made for.</p> <h2>Steam/Gamescope Setup</h2> <p>First some manual setup for Steam, I had to enable Remote Play and pair my laptop to my desktop for the first time. when you first try to connect to a device to remote play from it, you need to enter a PIN on the host.</p> <p>Luckily I have a monitor plugged into my desktop so I just turned that on, opened Steam in a graphical session, and got everything set up. Hopefully I only have to do this once (but also Steam loves to clear my preferences so we'll see).</p> <p>My NixOS setup for steam and gamescope is this:</p> <pre><code>{ programs.steam = { enable = true; remotePlay.openFirewall = true; extest.enable = false; # I'll explain this in a sec. }; programs.gamescope = { enable = true; capSysNice = false; # I'll explain this too. }; } </code></pre> <h3>Extest</h3> <p>We disable extest since it's not really needed, and it will panic when you try to send any input to a game Steam launches. <code>false</code> is the default so you can omit the line, but figured I should mention it.</p> <h3>capSysNice</h3> <p>This is an <a href="https://github.com/NixOS/nixpkgs/issues/351516">already discussed</a> issue. The way I'm launching gamescope is causing issues with bubblewrap passing the sysnice capability to Steam. I just disable that and renice it later.</p> <p>I'm like 80% sure this is a skill issue on my part to be honest. Most stuff says this has been fixed, at least in the NixOS module for Steam.</p> <h2>Running Steam</h2> <p>With Steam set up and those options enabled, we can run gamescope. My laptop uses a 2256x1504 resolution so I'm setting that here. I set both gamescope's resolution (<code>-W/-H</code>) and Steams (<code>-w</code>/<code>-h</code>).</p> <pre><code>gamescope -W 2256 -H 1504 -w 2256 -h 1504 --backend headless --steam -- steam -tenfoot -pipewire-dmabuf </code></pre> <p>We launch gamescope with our preferred resolution, with a headless backend and Steam integration enabled. Then we launch Steam in big picture mode.</p> <p>I'll usually start this in a GNU screen session so it doesn't stop Steam if my SSH session drops.</p> <pre><code>screen -S steam # Before running gamescope </code></pre> <h2>Re-nicing</h2> <p>Finally just renice all gamescope processes because it can start to degrade in performance after a while allegedly.</p> <pre><code>sudo renice -n -20 -p $(pgrep -d " " "gamescope") </code></pre> <p>Not the best solution but works fine. You could also repeat this for Steam and the game you're running once it launches.</p> <h2>Final Results</h2> <p>On a wired connection all the games I've tested are responsive and work very well besides some minor issues that are more a Linux/NixOS thing™ than specific to this. Here's a screenshot from Portal 2 with the overlay enabled.</p> <p><img src="../../assets/blog/steamrp_portal2.webp" alt="Portal 2 metrics on Remote Play" /></p> <p>Consistently I get &lt;1ms input latency and ~20ms display latency in-game, which for me is perfectly acceptable. I'm able to have all the settings on high now without my laptop becoming a nice and toasty 100°C.</p> <p>The nice thing is how I can choose to start this whenever I want remotely; I don't need a Steam session always open on a display on my desktop. I do wish Steam would let me add non-steam games in the big-picture UI because I still have to do that manually.</p> <h2>Conclusion</h2> <p>I'm sure Sunshine is way better in both performance and versatility but this works well enough for me and requires far less setup that I can deal with it. Security-wise I didn't want to introduce an entirely new service just to stream games, especially since it has an entire auth system to set up.</p> <p>Connecting via SSH is already easy since I use keys, and Steam handles everything for the actual remote play authentication for me already, so I see it as a much simpler setup.</p> <p>Of course Sunshine does a lot more than Steam so it's not really a fair comparison. I might end up trying it anyways to see how well it works.</p> <p>First blog post in 2 years, maybe I'll make another one this decade!</p> Custom Screen Capture Flowhttps://bwc9876.dev/blog/posts/wip_screen_captures/https://bwc9876.dev/blog/posts/wip_screen_captures/An adventure in making scripts to capture screen shots and recordings.Thu, 25 Jul 2024 00:00:00 GMT<p>I've recently been going down the path of madness known as customizing my desktop and I figured I'd share some neat scripts and setups I've done along the way.</p> <p>Today I'll go into some scripts I've been working on to capture screen shots and recordings. They allow selecting regions of the screen and specific windows, and I also made it so you can edit them afterwards.</p> <h2>Background</h2> <p>My entire system and home folder is managed by <a href="https://nixos.org">NixOS</a>, so I have a <a href="https://github.com/Bwc9876/nix-conf">configuration repository</a> where all my scripts and configs can be found, I'll reference them throughout this post and provide links to the current version of each so you can see if I've updated them since this post.</p> <p>Currently I use <a href="https://hyprland.org">Hyprland</a> as my window manager, and have been duct-taping components together to make my own little Desktop Environment around it.</p> <p>I also like to use <a href="https://nushell.sh">NuShell</a> as my shell, and these scripts will be written in it. If you haven't checked out NuShell yet, I highly recommend it!</p> <h2>Screenshots</h2> <p>First is the script to take screenshots. This is a relatively simple script as it simply builds on top of <a href="https://github.com/hyprwm/contrib/tree/main/grimblast">grimblast</a> with some nice QoL features.</p> <p>To install grimblast, all I have to do is add it to my <code>environment.systemPackages</code>:</p> <pre><code>{ environment.systemPackages = with pkgs; [ # ... grimblast libnotify # For notifications xdg-utils # For opening files # ... ]; } </code></pre> <p>Grimblast will automatically save screenshots to <code>XDG_SCREENSHOTS_DIR</code>, I manually set this in my <em>home manager</em> config with:</p> <pre><code>{ xdg.userDirs.extraConfig.XDG_SCREENSHOTS_DIR = "${config.home.homeDirectory}/Pictures/Screenshots"; } </code></pre> <p>Grimblast will name the screenshots with the current date and time, which works for me.</p> <p>Now along to actually using grimblast, I'll create a new script and put in in my config somewhere, we'll call it <code>screenshot.nu</code>. I usually like to place any non-nix files in a folder called <code>res</code> at the root of my config, we'll get to actually calling this script once we're done writing it.</p> <p>To start out we need to call grimblast, I like to use <code>copysave</code> as the action as I like having it immediately in my clipboard, and having it saved for later. I'll also add <code>--freeze</code> which simply freezes the screen while I select the region to capture.</p> <pre><code>let file_path = grimblast --freeze copysave </code></pre> <p>grimblast will then return the path to the saved screenshot, which we save in <code>file_path</code>. If the user were to cancel the selection (press escape), <code>file_path</code> would be empty, so we want to make sure to check for that so we're not trying to open a non-existent file.</p> <pre><code>if $file_path == "" { exit 1 } </code></pre> <p>Now the main part, we'll send a notification that the screenshot was saved, and have options for it.</p> <p>I want four actions for the screenshot:</p> <ul> <li>Open</li> <li>Open Folder</li> <li>Edit</li> <li>Delete</li> </ul> <p>Also since grimblast saves the screenshot as a png, I can pass it as the icon of the notification.</p> <pre><code>let choice = notify-send --app-name=screengrab -i $file_path -t 7500 --action=open=Open --action=folder="Show In Folder" --action=edit=Edit --action=delete=Delete "Screenshot taken" $"Screenshot saved to ($file_path) and copied to clipboard" </code></pre> <p>A long command here, <code>notify-send</code> allows us to send a notification to the currently running notification daemon. In my case I'm using <a href="https://github.com/ErikReider/SwayNotificationCenter">swaync</a>.</p> <ul> <li><code>--app-name</code> is the name of the application that sent the notification, I say screengrab here so swaync will show an icon in addition to the image, also so I can play a camera shutter sound when the notification is sent.</li> <li><code>-i</code> is the icon to display in the notification, in this case the screenshot we just took.</li> <li><code>-t</code> is the time in milliseconds to show the notification</li> <li><code>--action</code> is actions to display in the notification, <code>name=Text</code></li> <li>First position argument is the notification title, and second is the body.</li> </ul> <p>With that we get a neat notification when we screenshot.</p> <p><img src="@assets/blog/wip1_screenshot_notif.webp" alt="A notification that I just took a screenshot with the screenshot visible" /></p> <p>Now we need to handle each action, the chosen action is returned by notify-send, so we can match on that.</p> <ul> <li>"Open" and "Open Folder" are pretty simple, just pass <code>$file_path</code> and <code>$file_path | path dirname</code> to <code>xdg-open</code></li> <li>"Edit" I'll simply pass the file path to my editor, I chose <a href="https://github.com/jtheoof/swappy">swappy</a> because of it's simplicity and ease of use.</li> <li>"Delete" I'll just remove the file.</li> </ul> <pre><code>match $choice { "open" =&gt; { xdg-open $file_path } "folder" =&gt; { xdg-open ($file_path | path dirname) } "edit" =&gt; { swappy -f $file_path } "delete" =&gt; { rm $file_path } } </code></pre> <p>And that's it! I now have a fairly robust screenshot script.</p> <h3>Screenshot Invocation</h3> <p>Now in terms of actually calling it I'll be binding it to <code>Win</code> + <code>Shift</code> + <code>S</code> in Hyprland, as well as <code>PrintScreen</code>.</p> <p>In home manager I simply have to add these strings to my Hyprland binds</p> <pre><code>{ wayland.windowManager.hyprland.settings.bind = [ # ... ",Print,exec,nu ${../res/screenshot.nu}" "SUPER SHIFT,S,exec,nu ${../res/screenshot.nu}" # ... ]; } </code></pre> <p>Now by switching to my new config (and making sure to stage <code>screenshot.nu</code> of course), I can take screenshots with a keybind!</p> <h2>Screen Recordings</h2> <p>This will be a bit more involved mainly because something like grimblast doesn't exist for screen recordings. Looking at existing solutions I couldn't find any that I really liked, mostly because they involved some additional UI. To be clear this script will be for <em>simple</em>, <em>short</em> recordings, long-term stuff I'll still prefer to use something like OBS.</p> <p>For the actual screen recording I'll be using <a href="https://github.com/ammen99/wf-recorder">wf-recorder</a>.</p> <pre><code>{ environment.systemPackages = with pkgs; [ # ... wf-recorder libnotify # For notifications xdg-utils # For opening files slurp # Will explain this later # ... ]; } </code></pre> <p>First and foremost location, I chose to use <code>~/Videos/Captures</code> for my recordings. I didn't set an environment variable for this, it'll be hardcoded in the script.</p> <pre><code>let date_format = "%Y-%m-%d_%H-%M-%S" let captures_folder = $"($env.HOME)/Videos/Captures" if not ($captures_folder | path exists) { mkdir $captures_folder } let out_name = date now | format date $"($captures_folder)/($date_format).mp4" </code></pre> <p>This will handle determining the folder and name for the recordings, and creating the folder if it doesn't exist.</p> <p>Next up I want to have a similar selection process to the screenshot script, to do this I'll use <a href="https://github.com/emersion/slurp">slurp</a> to select areas of the screen, which is what grimblast uses under the hood.</p> <p>In addition, grimblast does some communication with Hyprland to get window information such as position and size, this lets you select a window to take a screenshot of. I'll be getting that info manually from Hyprland using NuShell instead:</p> <pre><code>let workspaces = hyprctl monitors -j | from json | get activeWorkspace.id let windows = hyprctl clients -j | from json | where workspace.id in $workspaces let geom = $windows | each { |w| $"($w.at.0),($w.at.1) ($w.size.0)x($w.size.1)" } | str join "\n" </code></pre> <p>This gets all the geometry in a format <code>slurp</code> will be able to parse and use.</p> <pre><code>let stat = do { echo $geom | slurp -d } | complete if $stat.exit_code == 1 { echo "No selection made" exit } </code></pre> <p>I do <code>complete</code> here to get the exit code of the slurp command, if it's 1 then the user cancelled the selection and similar to the screenshot script I'll exit.</p> <p>Now it's time to actually record, the stdout of <code>slurp</code> contains the geometry that we want to capture, so we'll pass that to <code>wf-recorder</code> with the <code>-g</code> flag:</p> <pre><code>wf-recorder -g ($stat.stdout) -F fps=30 -f $out_name </code></pre> <p>Pretty simple command, <code>-g</code> is the geometry to record, <code>-F</code> is the format options, and <code>-f</code> is the output file.</p> <p>Now we'll run into an issue if we run this script and start recording, there's no way to stop it! I'll cover how we're going to get around that when it comes to setting up keybinds.</p> <p>Assuming <code>wf-recorder</code> stops, we'll send a notification to the user that the recording is done:</p> <pre><code>let action = notify-send --app-name=simplescreenrecorder --icon=simplescreenrecorder -t 7500 --action=open=Open --action=folder="Show In Folder" --action=delete=Delete "Recording finished" $"File saved to ($out_name)" </code></pre> <p><img src="@assets/blog/wip1_screenrec_notif.webp" alt="A notification that I just took a screen recording with a video camera icon visible" /></p> <p>Most arguments are the same here as the screenshot script, the only difference is the icon and app name. The actions are also basically the same, so I'll leave out the explanation and just show the handler:</p> <pre><code>match $action { "open" =&gt; { xdg-open $out_name } "folder" =&gt; { xdg-open $captures_folder } "delete" =&gt; { rm $out_name } } </code></pre> <h3>Start/Stop Recording</h3> <p>Now to actually call the script, I'll bind it to <code>Win</code> + <code>Shift</code> + <code>R</code> in Hyprland.</p> <p>However, we're going to do something special with the <code>exec</code> line here. Instead of simply calling the script we're going to check if <code>wf-recorder</code> is already running, if this is the case we can send <code>SIGINT</code> to it to make it stop recording, meaning our script will continue and show the notification.</p> <pre><code>{ wayland.windowManager.hyprland.settings.bindr = [ # ... "SUPER SHIFT,R,exec,pkill wf-recorder --signal SIGINT || nu ${../res/screenrec.nu}" # ... ]; } </code></pre> <p><code>pkill</code> here will exit with code <code>1</code> if it doesn't find any processes to kill, so the <code>||</code> will run our script if <code>pkill</code> fails.</p> <p>Note that I did this on <code>bindr</code>, this means the keybind will only happen once the R key is <em>released</em> rather than pressed. This is to prevent a weird issue I ran into where the recording would stop immediately after starting.</p> <p>And that's it! We can now screen record with ease. It won't record audio (might do an additional keybind in the future) and it also doesn't copy the recording to the clipboard, but it works pretty well for short videos.</p> <h2>Full Scripts</h2> <h3>Screenshot Script</h3> <pre><code>#!/usr/bin/env nu let file_path = grimblast --freeze copysave area if $file_path == "" { exit 1; } let choice = notify-send --app-name=screengrab -i $file_path -t 7500 --action=open=Open --action=folder="Show In Folder" --action=edit=Edit --action=delete=Delete "Screenshot taken" $"Screenshot saved to ($file_path) and copied to clipboard" match $choice { "open" =&gt; { xdg-open $file_path } "folder" =&gt; { xdg-open ($file_path | path dirname) } "edit" =&gt; { swappy -f $file_path } "delete" =&gt; { rm $file_path } } </code></pre> <p><a href="https://github.com/Bwc9876/nix-conf/blob/main/res/screenshot.nu">Most recent version on GitHub</a></p> <h3>Recording Script</h3> <pre><code>#!/usr/bin/env nu let date_format = "%Y-%m-%d_%H-%M-%S" let captures_folder = $"($env.HOME)/Videos/Captures" if not ($captures_folder | path exists) { mkdir $captures_folder } let out_name = date now | format date $"($captures_folder)/($date_format).mp4" let workspaces = hyprctl monitors -j | from json | get activeWorkspace.id let windows = hyprctl clients -j | from json | where workspace.id in $workspaces let geom = $windows | each { |w| $"($w.at.0),($w.at.1) ($w.size.0)x($w.size.1)" } | str join "\n" let stat = do { echo $geom | slurp -d } | complete if $stat.exit_code == 1 { echo "No selection made" exit } wf-recorder -g ($stat.stdout) -F fps=30 -f $out_name let action = notify-send --app-name=simplescreenrecorder --icon=simplescreenrecorder -t 7500 --action=open=Open --action=folder="Show In Folder" --action=delete=Delete "Recording finished" $"File saved to ($out_name)" match $action { "open" =&gt; { xdg-open $out_name } "folder" =&gt; { xdg-open $captures_folder } "delete" =&gt; { rm $out_name } } </code></pre> <p><a href="https://github.com/Bwc9876/nix-conf/blob/main/res/screenrec.nu">Most recent version on GitHub</a></p> Welcome to my blog!https://bwc9876.dev/blog/posts/hello_world/https://bwc9876.dev/blog/posts/hello_world/How I built my blogSun, 15 Oct 2023 00:00:00 GMT<p>import CowSay from "@components/blog/CowSay.astro";</p> <h2>Hey there</h2> <p>I decided to make this blog as a way to track my progress in learning new things. I hope you enjoy your stay!</p> <p>This first post is going into a bit of detail about how I made this blog.</p> <h2>Making The Basic Blog</h2> <p>Astro has a wonderful feature called the <a href="https://docs.astro.build/en/guides/content-collections/">content framework</a>, an extremely powerful way to easily create many pages with simple markdown and some frontmatter.</p> <p>First thing you have to to do is create a folder called <code>content</code> in the src directory. I already had some content setup because of the projects parts of this site.</p> <p>Inside the content folder you place a <code>config.ts</code> which will contain the schemas for your content's frontmatter. I'll just focus on my blog posts for now.</p> <pre><code>const blogPostsCollection = defineCollection({ schema: z.object({ title: z.string(), date: z.date(), summary: z.string(), cowsay: z.string() }) }); </code></pre> <p>This contains the metadata each blog post will need to have in order for my site to render it.</p> <p>Then, we export an object named <code>collections</code> which Astro will pick up and generate TS bindings for.</p> <pre><code>export const collections = { posts: blogPostsCollection }; </code></pre> <p>Now we can get to writing some content! Make a folder with the same name as the <em>key</em> of the collection you want to write for. In this case, <code>posts</code>.</p> <p>Then create a markdown file and start writing! Here's a little excerpt of what <a href="https://github.com/Bwc9876/portfolio-site/tree/main/src/content/posts/hello_world.mdx">this page looks like</a>:</p> <pre><code>--- title: Welcome to my blog! date: 2023-10-15 summary: How I built my blog. cowsay: Hello World! --- ## Hey there </code></pre> <p>The frontmatter is the part between the <code>---</code> and <code>---</code>. This is where you put metadata for the post. The <code>cowsay</code> field is a special one that I made up. It changes what the cow says in the header of the page. I'll get to that later.</p> <p>Now that we have some content, we can start writing some code to render it!</p> <p>I start off by making a <code>blog</code> folder in the <code>src/pages</code> directory. This is where all my blog related pages will go. I then make a <code>index.astro</code> file which will be a directory of all posts on the site.</p> <pre><code>--- import Layout from "@layouts/Layout.astro"; import { getCollection } from "astro:content"; const blogEntries = await getCollection("posts"); --- &lt;Layout title="The Cowsay - Ben C's Blog"&gt; &lt;h1&gt;The Cowsay - Ben C's Blog&lt;/h1&gt; &lt;p&gt;Here you'll find my blog posts, most recent first&lt;/p&gt; { blogEntries.map((p, i) =&gt; ( &lt;&gt; {i === 0 &amp;&amp; &lt;hr /&gt;} &lt;hgroup&gt; &lt;h2&gt; &lt;a href={`/blog/posts/${p.id}`}&gt;{p.data.title}&lt;/a&gt; &lt;/h2&gt; &lt;h3&gt; {p.data.date.toLocaleDateString("en-us", { weekday: "long", year: "numeric", month: "short", day: "numeric" })} &lt;/h3&gt; &lt;/hgroup&gt; &lt;p&gt; {p.data.summary}&amp;nbsp;&amp;nbsp;&lt;a href={`/blog/posts/${p.id}`}&gt;Read More&lt;/a&gt; &lt;/p&gt; &lt;hr /&gt; &lt;/&gt; )) } &lt;/Layout&gt; </code></pre> <p>Great! I'll probably fiddle with it in the future but it's a good start. Now we need to make a page for each post. To make my URLs look nice I'm going to create a subfolder within <code>blog</code> called <code>posts</code> and then place a <code>[...id].astro</code> in there. This will allow me to use <code>getStaticPaths()</code> to define the paths for each post.</p> <pre><code>--- import Layout from "@layouts/Layout.astro"; import { CollectionEntry, getCollection } from "astro:content"; export const getStaticPaths = async () =&gt; { const posts = await getCollection("posts"); return posts.map((entry) =&gt; ({ params: { slug: entry.id }, props: { entry } })); }; const { entry } = Astro.props as { entry: CollectionEntry&lt;"posts"&gt; }; const { Content } = await entry.render(); --- &lt;Layout title={entry.data.title} description={entry.data.summary}&gt; &lt;h1&gt;{entry.data.title}&lt;/h1&gt; &lt;Content /&gt; &lt;/Layout&gt; &lt;style is:global&gt; main img { border: solid 1px var(--text) !important; border-radius: 5px; } &lt;/style&gt; </code></pre> <p>Amazing! I'll spare you the image this time since... you're... look at it. But now we have a blog post page! Now anytime I want to make a new post I just have to make a new markdown file and it'll be rendered on the site.</p> <h2>An Outline</h2> <p>Now that we have a basic blog, I want to add a few more features to it. First I want to add the ability to see all headers in a post. This should be pretty easy to do. Astro automatically parses all the headers for us and lets us access them in the <code>entry</code> object.</p> <p>First we need to grab the headings from when we rendered the page:</p> <pre><code>const { Content, headings } = await entry.render(); </code></pre> <p>Then we need to map those to HTML</p> <pre><code>&lt;div class="toc"&gt; &lt;!-- Extra div so we can make it sticky --&gt; &lt;div&gt; &lt;span&gt;On This Page&lt;/span&gt; &lt;ul&gt; { headings.map((h) =&gt; ( &lt;li&gt; &lt;a href={`#${h.id}`}&gt;{h.text}&lt;/a&gt; &lt;/li&gt; )) } &lt;/ul&gt; &lt;/div&gt; &lt;/div&gt; </code></pre> <p>Finally some simple styles and layout</p> <pre><code>&lt;style&gt; /** Wrapper is going around everything to make the table of contents appear on the right of the page **/ div.wrapper { display: flex; flex-direction: row; gap: 4rem; } div.toc { width: 100%; } div.toc div { top: 5rem; margin-top: 1rem; position: sticky; } div.toc ul li { list-style-type: none; } &lt;/style&gt; </code></pre> <p>Finally, I want to make sure on smaller screen sizes the table of contents appears at the top rather than the side so it doesn't take up too much space.</p> <pre><code>div.wrapper { display: flex; flex-direction: column-reverse; gap: 1rem; } @media (min-width: 1200px) { div.wrapper { flex-direction: row; gap: 4rem; } } </code></pre> <h2>The Cowsay</h2> <p>Now for the fun part. I want to make a little cow that says something in the header of each post. I'm going to use the <code>cowsay</code> field in the frontmatter to do this. I'll also provide a CowSay component that will render the cow and the text. This way I can use it MDX for admonitions.</p> <p>First I need to make a component that will render the cow. I'm going to use the <a href="https://www.npmjs.com/package/cowsay">cowsay</a> package to do this.</p> <pre><code>--- import * as cowsay from "cowsay"; type Props = { color?: "warn" | "info"; } &amp; cowsay.IOptions; const { color, ...cowOptions } = Astro.props; const cowText = cowsay.say(cowOptions); --- &lt;pre class={color}&gt; {cowText} &lt;/pre&gt; &lt;style&gt; pre { padding: 1rem; } pre.warn { color: yellow; background-color: rgb(25, 25, 0); } pre.info { color: cyan; background-color: rgb(0, 25, 25); } &lt;/style&gt; </code></pre> <p>Now I can link it up in my blog post page!</p> <pre><code>&lt;CowSay text={entry.data.cowsay} /&gt; </code></pre> <p>Et voila! A cow that says something in the header of each post! I'll probably make it a bit more fancy in the future but for now it's good enough.</p> <p>I can also use it for admonitions in MDX!</p> <pre><code>&lt;CowSay color="warn" e="&gt;&lt;" text="Warning!" /&gt; &lt;CowSay color="info" e="^^" T="U" text="Info!" /&gt; </code></pre> <p>&lt;CowSay color="warn" e="&gt;&lt;" text="Warning!" /&gt; &lt;CowSay color="info" e="^^" T="U" text="Info!" /&gt;</p> <h2>Conclusion</h2> <p>I'm really happy with how this blog turned out. I'm going to keep working on it and adding new features as I go. I'm also going to try and write more posts in the future. I hope you enjoyed this one!</p> <p>&lt;CowSay e="^^" text="Adiós!" /&gt;</p>