diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2cae22 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/build/ +/dist/ diff --git a/content/posts/ai-reflections-fatigue.md b/content/posts/ai-reflections-fatigue.md new file mode 100644 index 0000000..12d3a2f --- /dev/null +++ b/content/posts/ai-reflections-fatigue.md @@ -0,0 +1,175 @@ +--- +title: "AI Reflections: Fatigue" +topic: "Engineering" +date: "Apr 13 2026" +description: "Why I'm taking a break from AI-assisted coding." +--- + +This year I've taken a deeper interest in how I use AI. + +There are nearly endless ideas and takes on AI, and particularly in my bubble, LLM-assisted coding. For every measured, grounded take, it feels like there are 10 "hype" takes. It's been challenging to wade through all of the information and settle on coherent thoughts. This reflection is me trying to put into (relatively unedited/stream-of-consciousness) words on what my current thoughts are on the matter. + +First off, in the beginning of the year (January, 2026) I had just finished reading Adam Grant's novel, [Think Again](https://adamgrant.net/book/think-again/). One of the main themes in _Think Again_, is to be both more open-minded in our interpretations of the world _and_ more scientific about how we come to conclusions. Generally, Grant encourages us to exercise the "rethinking" process more liberally, and especially when we're most dogmatic or for topics where there is a lot of ambiguous/conflicting information. + +At the same time, more powerful models released from major labs, and tools like Claude Code, Open Code, and other powerful agentic harnesses gained traction. Outside of the Linkedin fueled hype of people claiming that LLMs were "doing the work of dozens of engineers", I started seeing other engineers I respected across the industry experimenting more with these tools (or at the very least, rethinking their positions on them). + +I remember in late 2025 and early January, I _felt_ like AI was making me more productive, for some definition of productive that I couldn't quite pin down. + +However, it didn't feel strictly better. I had a number of hypotheses around how AI was impacting my work (both in my personal/side projects and at my day-job, a Principal Engineer at CapitalRx, where I'm focused mostly on consumer product development and architecture). + +**Here were my hypotheses:** + +``` +"hypotheses": [ + "I procrastinate less with AI assistance", + "I ship more code with AI assistance", + "Code quality is slightly lower with AI but still acceptable", + "I write more tests and documentation with AI", + "I retain less understanding of the codebase with heavy AI use", + "Moderate AI use is more fulfilling than no AI or full AI" + ] +``` + +There were a number of other "minor" ideas I had, but these were the main ones I wanted to try and test in a self-structured experiment. + +In order to carry out that experiment, I built (with Claude Code and LLM assistance) a tool called [devex](https://github.com/wellwright-labs/devex). + +You can check it out to get an idea of what it does, but the basic idea was just a simple self-capture system in the terminal to capture things like mood, commits/lines of code, sentiment, and how well I could recall information about what I was building. I built the tool around the idea of "blocks", which are just timeboxes (initially 2 weeks) wherein I would set a condition like _No AI_, _Some AI_, _Heavy AI_. You can read more about exactly what I meant by those [in this post](https://wlls.dev/blog/devex#experiment), but the gist is: + +- No AI = zero AI at all (no AI-powered search, no chatbots, no codegen, no agents, no inline-assist, zip) +- Some AI = primarily hand coding, AI allowed for rubber ducking, planning, searching, tedious tasks +- Heavy AI = primarily agentic coding through Claude Code, Open Code, or Zed (and planning / leaning on Claude web for research, overnight tasks, remote development, sandboxes, etc) + +I followed this fairly dogmatically. + +What I **expected** was to come out of this with some data that would let me have a more nuanced opinion of how AI was impacting my own workflow. I expected to see something inbetween the extremes of what I saw online. Not an "AI transformed my whole life and I'm never writing code by hand again" and also not "I will never touch an LLM and you can never convince me otherwise". + +## Conclusions + +In short, I am extremely concerned about how AI impacted my workflow, and not for the reasons I expected. I'm not going to share the data here (for privacy / IP reasons primarily -- I work in healthtech), but I will share broad strokes percentages where I can. + +I ran blocks of this back to back for nearly 3 months (Q1). The first 6 weeks were simply split into 3 blocks (Some AI, Heavy AI, No AI). After that I decided to try a simpler 3 weeks of Heavy AI and then 3 weeks of No AI, with a few days off in the middle. + +Let's take each hypothesis in turn. + +### I procrastinate less with AI assistance: False + +This is a very weird one. When I wrote this hypothesis, I wanted to look at something I was noticing, which was that I was _starting_ a lot more stuff. More projects, more repos, more ideas, more everything. My insight at the time was "Wow, it's really easy for me, someone who has struggled with procrastination, to simply sit down and say `Hey Claude, here's what I need to do...`" (paraphrased) and _get started_ on whatever I needed to do. I saw this as similar to the 2 minute rule or whatever it's called where you're supposed to just do something for 2 minutes, and that often is enough to get you going. It's a brain hack kind of thing. + +Generally speaking, I found it **true** during the periods where I was allowed to use AI that I would start things more readily. **HOWEVER**, in reflection, I actually think I procrastinated _more_. + +By this I mean, I did _start_ plenty of things (arguably way too many things). But often the things I started doing weren't actually moving the needle in any meaningful sense. Ironically, much of the work I did (both at my day job and in personal projects) was shallow, unimportant, or entirely a distraction in itself. It was so easy to start doing something, that I rarely actually built anything that felt meaningful. Or if it did feel meaningful, I would end up working on something else at the same time, due to how easy it was to hop onto a new idea or project. + +Let's just look at everything I _started building_ (just side stuff) over the past few months: + +1. Breakline - a private project for an online game where users could collect popular news stories as trading cards and autobattle them +2. [Beacon](https://github.com/devdumpling/beacon) - a privacy-first analytics platform built on Gleam and Postgres intended for analytics for deep user sessions in SPAs primarily +3. [my wife's blog](https://kbean.me/) - a blog for my wife, built on SvelteKit and Cloudflare +4. An unnamed, private frontend stack evaluation tool (compares performance and "complexity") across different metaframeworks in a relatively complex app +5. A [forked version of opencode](https://github.com/devdumpling/opencode) where I ripped out the telemetry for testing and then promptly abandoned it +6. [traverse](https://github.com/devdumpling/traverse) - another performance capture tool for frontend stacks +7. [npmx.dev](https://github.com/devdumpling/npmx.dev) - a few early contributions to npmx.dev, primarily the "docs" feature similar to jsr where you can see autogenerated docs based on type information +8. A private design system test to compare different design system methodologies +9. syl - a highly-personalized notetaking and organization system built to improve on issues I had with my Obsidian markdown vault +10. syl-tui - a ratatui interface for interacting with `syl` +11. syl-bootstrap - a nix-inspired way to package up all of the tools I use to develop in setup scripts I can use across all of the machines I work on +12. syl-read - a content tracking system built into syl for managing and cataloging content I'm consuming (books, talks, games, whatever) +13. nahel - a custom Rust-based agentic harness inspired by OpenCode, Pi, Claude Code, and Codex built to interact directly with syl and act as more of a "thought medium" and personal assistant + +...and about a dozen half-baked game prototypes, only one of which was truly unique, and none of which were fun as a prototype. + +This *doesn't even include* all of the projects that I "started" as conversations or mini-prototypes that never even made it to a repository for tracking. It also entirely ignores all of the work I did during my day job *and* the stealth-startup that I'm a technical advisor on. + +If you're like me and you look at that list and go "how the fuck did you do so many things?", the answer is **I didn't**. + +90% of this I started and abandoned, realized were distractions from important work, or felt ashamed/hollow that I couldn't add features to because *I didn't know how they worked after a certain point*. + +**I procrastinated more with AI**. I started a lot of stuff, and some of that stuff was legitimately interesting or taught me a lot. So I don't think it's fair to say it was "wasted" effort necessarily, but it certainly wasn't the highest value-spend for my time. + +### I ship more code with AI assistance: True + +Similar to above, this one is messy, and at this point you can probably guess why. + +Yes, I shipped "more code", but as above the code wasn't exactly high-value. Half of the projects above I'm sort of nervous to even post about, just because of how silly the codebases look in retrospect. Don't think I have too much more to add here, except that I fully expect this is a dimension that will get fully gamed and misunderstood by companies. It's a common theme I'm seeing as I look back and consider how AI has impacted my work, which is that there is a clear throughline of more or what Cal Newport calls "pseudowork", work that looks like real work, but is really either busywork or low-value work. Note that I'm not saying this is zero value work. It feels more like what I've heard referred to as "snacking" or "preening" in software development circles. + +These are ideas that come from [Staff Engineer: Work on what matters](https://staffeng.com/guides/work-on-what-matters/). The gist is that, especially for someone at my level, my focus should be on the hard problems that require my skillset and deep focus. These are typically high effort, high impact. I'm not saying that all of my work *is* that, but at the very least I strive to spend as much time as possible on high impact work. At my day job that might mean whatever is going to result in more revenue, which is obviously a very broad category, and I'll leave it up to the reader to break down. + +What I want to avoid is the low impact stuff, particularly low impact, high effort. What I saw in my data was that, because of how "easy" AI made certain work, I ended up doing a lot of low impact, easy work (in both my day job and out) when I was heavily focused on using AI. When I was on strict "No AI" blocks, I saw the reverse. I rarely sat down and knocked out whatever the unimportant thing was, as I didn't feel I had time to. I often grappled with starting a hard thing for a while, but ultimately the things I ended up doing mattered a lot more. + +### Code quality is slightly worse with AI but still acceptable: Unclear + +This one was a mixed bag, and dependend a lot on what I was working on. + +Generally, I'd say the "code" was acceptable in the sense that it worked (often with a lot of steering when I was doing "heavy" AI periods). But because I was churning through so much code, inevitably I would get to a point where I didn't fully understand what I was changing. This led to what felt like what I'll call "complacent engineering", where the things I were building would have never held up to my bar if I was hand-writing them, but because of the general speed of development I didn't really care to fix it. Worse, the more this complacent code got in, the less I cared. This showed up in sentiment in my data like "okay", "works", "fine", etc. + +Now I can in retrospect go look at the code quality written during the heavier AI blocks and say definitively it's worse _architecturally_. Much of it is fine syntactically, I'd argue, but the system is incoherent and messy. This held up even when I tried explicitly to course correct against this, adding in hand-written tests, documentation, or otherwise adding friction to the agent adding to the codebase. I think the rate at which complacent code accumulated was slower, but still faster than if I was handwriting. + +I think a separate reason this happened is I would often build things that, again, weren't actually moving the needle and would bloat codebases. + +### I retain less understanding of the codebase with AI: True + +This one is not only true, it is the biggest driver of my current hesitance to continue using these tools, even with guardrails and sophisticated harnesses. + +In short, **I simply do not remember the code I'm writing when I use AI to write it**. Worse, I noticed I'm not developing the _mental representations_ that I need to succeed at my level. I can't go into a meeting and vibe explain architecture decisions to stakeholders, project managers, or my boss. I have to know what the fuck I'm talking about. + +And unfortunately, I often find that I simply... don't. Or at least, I don't with the conviction I do when I'm forced to grok and write it myself top to bottom. + +I'm surprised at the extent of this as well. It's not simply that I don't remember a particular algorithm I used or the implementation details of some feature. No, I literally don't remember how the system works. Looking back through the projects above as I listed them here, I could not name a single file in Breakline by memory, a project I built not 3 months ago. I couldn't even tell you (without looking) what the stack is in full. I know there's Svelte rendinerg UI, Zero sync engine assisting with autobattling, and Gleam handling some backend state machines in there somewhere, but I don't really remember how they fit together to accomplish the goals of that particular project. If I had to add a feature to it or fix a bug, I'd have no idea where to start. + +You don't have to believe me, and I'm not saying this is/should be anyone else's experience, but it confirms a long-held suspiscion I've had about these tools on my own workflow. It reminds me of similar conclusions I made around notetaking back when I was studying in college. I absolutely hated taking notes by hand. Also, since I was studying computer science, it felt reaffirming to take notes in markdown on an editor on my laptop. It was an identity thing. + +But I wasn't really retaining hardly anything I wrote down. A professor encouraged me to try taking notes by hand instead (and yes I know there's some shaky evidence here). For me, the difference was immediately noticable, and after that even when I did take notes on a laptop (which I still did when I really wanted to capture everything since I could type way faster than write) I would still go back and transfer those notes by hand onto paper. + +I suspect something similar happens when I write code. I felt this early on and tried to adjust my workflow a touch by having AI write a "first draft" and then hand editing it after. But honestly this seemed to just take more time for most things than if I just did it myself from the beginning. Not everything, but lots of things. The areas where it didn't feel that way were extremely tedious tasks where there was a clear input and output. For example, at one point I needed to quickly convert a bunch of RGBA colors in figma to an Oklch representation in code and then demo them in an app to check color variance. This was the kind of thing that done by hand would've taken me a long time, and I didn't really care about the code at all. I just needed a quick yes or no on whether it made sense to convert between the two for a design system I was working on. I still feel like there's some cognitive trade off here, but I suspect there are many tasks like this that being able to defer it to an LLM makes sense. + +The problem is, in practice, I've found it very hard to reliably draw that line without overindulging. It's so, so easy to say "Okay Claude, I need you to do this one thing for me" and then doom-prompt into vibing an entire day of sloppy work that I don't retain the next day. This spirals in that now I either have to go back and spend more time grokking it to catch back up **or** (what I noticed myself more often doing) is just letting that turn into complacent code and letting the LLM figure it, further separating my mental representation of the codebase from reality. + +**To be clear, for me this is the most existential risk I saw over the past few months for my personal workflow.** My job, I find, is often to take all of the small things cumulatively and figure out what the next big thing needs to look like. I have found it significantly more challenging to do this and make "correct decisions" (for some definition of correct) in my work when I don't do the small things and take my time building the pieces by hand. When I don't form the mental representations, I degrade my value to my team. + +Perhaps more damning, is the emotional impact of this, which I cover in the next and final hypothesis. + +### Moderate AI use is more fulfilling than No AI or Full AI: False + +This is perhaps the point I discovered for myself that feels the most controversial. + +I already [wrote at length over a year ago](https://wlls.dev/blog/ai-reflections) about the intangible discontentment I had when using AI for prolonged periods, which at that time was primarily through autocomplete. + +And yet, it felt hard to dismiss the utility here, particularly with how... creatively energized I was by agentic AI (re: starting many projects, actioning many ideas). I expected that leaning too heavily into LLM-assisted coding would give me the same hollow, prideless feeling in my work that I experienced prior. But I also thought that not using AI would feel unsatisfying, or at the very least introduce FOMO. + +Full AI predictably felt hollow. Actually, I'd liken it more to an addiction. I experimented plenty with substances in college, and was 100% addicted to gaming in college as well (to my detriment). I'm not going to sit here and try to make hand-wavy parallels around how abusing substances or playing games 100 hours a week instead of studying/doing your responsibilities is identical to using AI. Sure, there's already plenty of research happening on the psychological impacts of using AI, particularly the findings on AI-induced psychosis. I'll leave that up to people more psychiatrically qualified, and I also don't think it's critical to my takeaway. + +When I say "i'd liken it more to an addiction", what I mean precisely is that I _felt similar physiologically_ to how I felt during my life when I was addicted to things. A "high" that is difficult to turn away from day-to-day, but ultimately left me feeling fatigued, hollow, shameful, and/or hopeless to some degree. I look back and 90% of what I built during the past quarter and take little pride in it. I know it sounds dramatic, but I would literally sneak off to my desk between hanging out with my kids to check a prompt and send the next one after hours. I would have 2 or 3 threads going at once in the background, researching something when I leaned all the way in. + +And in retrospect it made me feel kind of gross? + +There was little different between "Full AI" or "moderate AI" here, and I'm trying to stay grounded here an open to the possibility that perhaps this is a discipline thing and I just need to "find the right limits" or whatever for moderate AI usage, given how much utility there is. But the formerly addicted person in me also knows that's a slippery slope. + +## Some conclusions + +I have recently stumbled harder into the "Better Software" community (e.g. BSC 2025, Casey Muratori, Ginger Bill, and so on) and am at a point in my career where I'd simply prefer to lean into the "satisfying but hard" side of my work. I don't know what that means for my current AI-usage, as I feel like long term the impacts of LLM-assisted coding aren't going anywhere. As I mentioned above with the color-space example, there are so many "good examples" of AI-assisted coding that I've encountered over the past few months that I feel it'd be irresponsible to fully ignore the developments/not continue to iterate on my perspectives here. + +One major takeaway for me has been leaning further into de-anthropomorphizing all LLMs as much as possible. It feels somehow more reasonable to me to use a tool that uses an LLM as part of its system to achieve a desired output than it does to simply lean fully on a giant language model where simply predicting the next token "really well" is the entire feature. + +For example, I built `syl`, my "second brain" of sorts. One of the features I built into `syl` (by hand, for what it's worth) is a feature that takes all of my notes from a given time period plus some search terms, runs them through `fzf`, uses an LLM to find relationships between the results and throw that into some YAML, then another feature takes that and lets me visualize the data and drill into specific notes. Kind of like a more interactive, fuzzy-search enabled, dynamic mind-map. + +It feels like a very well-scoped problem, where the LLM part is simply one small piece of the toolchain and not the tool itself. + +On the other hand, I feel very weird about the agentic harness I built `nahel`. While this was a really educational project in itself, it doesn't really do anything different than Claude Code or any of these other agentic CLIs. It's more personalized to my workflow, sure, but the outcomes of it suffer from the same problems as everything I've discussed above. + +And on the "psychosis" front, it feels particularly weird in retrospect that I named it `nahel`. If you're not familiar, the names `syl` and `nahel` come from Brandon Sanderson's fictional universe Cosmere. In the Stormlight Archives (currently 5 massive high-fantasy books), one of the main characters, Kaladin, has a "bond" with a spirit-like character named _Sylphrena_ (who he affectionately calls _Syl_ for short). The two form a deep relationship and Syl enhances Kaladin's physical abilities and gives him pseudo-magical powers. That's beside the point, though. The name of the "bond" between them is called the _Nahel bond_, a lifelong bond between "Spren" (the type of creature Syl is) and Kaladin (a human). + +...and I went straight to this name for my agentic harness, the thing that connects me to my "second brain/knowledge base". See the problem? + +I wouldn't have considered it a problem (and am trying not to convey an overreaction here either), but it feels awkward looking back and noticing that I gave such an effectively intimate name to this technology wrapping an LLM. + +I continue to think there's utility in using LLMs for some generalized problems, of course, but am wary of the subtle effects it's had on my psyche and will be taking a good long break from using them. Will post further reflections after that! + +## Other References + +Here are some other level-headed takes I've come across over the past few months that are worth checking out, if what I wrote resonates with you at all: + +- [Mitchell Hashimoto's AI adoption journey](https://mitchellh.com/writing/my-ai-adoption-journey) +- [Should You Be A Carpenter, Wading Through AI, Casey Muratori + Demetri Spanos](https://www.youtube.com/watch?v=RJyPVLMyyuA) +- [AI Psychosis, HealthyGamer](https://www.youtube.com/watch?v=S6kRGJlugiw) +- [This is going to be very messy, Hank Green + Cal Newport](https://www.youtube.com/watch?v=8MLbOulrLA0) +- [Using AI in open source, Daniel Roe](https://roe.dev/blog/using-ai-in-open-source) diff --git a/content/posts/save-spaces-in-games.md b/content/posts/save-spaces-in-games.md new file mode 100644 index 0000000..b811ea5 --- /dev/null +++ b/content/posts/save-spaces-in-games.md @@ -0,0 +1,55 @@ +--- +title: "Save Spaces: Exploring Tranquility in Games" +topic: "Gaming" +date: "Sep 24 2020" +description: "How games teach us to revel in the calm—and why those peaceful moments matter." +archive: true +--- + +> **Note:** This is an archived piece from 2020 exploring game design and emotional resonance in interactive media. + +In the beginning, it's pitch black. You see nothing, but you hear the whisper of wind over water. The sounds build to a murmur, and you wake. You open your eyes to the familiar teal and blue-greens of the midday sky over the ocean. Clouds dot the horizon, accenting a picturesque ocean. + +You're viewing all of this from atop a wooden lookout on a small island, one of many connected by rope bridges. Far below there is a humble village, and you can make out the sounds of villagers trading, as well as a piglet snorting. The curious sounds of the village are replaced by a string quartet crescendo, followed by the inviting melody of a harp. As this happens, a warm ocean breeze tickles your skin. You feel as though you could lie atop this lookout forever. And then, off in the distance, you hear a piercing yet familiar cry: "Big brother!" The words "Outset Island" fade in at the top of the screen, and you are brought back to reality. + +What you just read is the introduction to _The Legend of Zelda: Wind Waker_. Did it make you feel calm? That's the intent. + +When we reminisce about our favorite games, we often talk about the heartbreaking moments, gripping narrative, or clever gameplay. What's not talked about is what sets up all of that: the calm before the storm. + +Throughout the history of video games, especially role-playing games, game developers have taken us through serene woods, charming villages, warm beaches, and many more peaceful places, places with tranquility that only a game could provide. Much like books or movies, they use tranquility to establish perspective and ground us into the world. They do this to make us want to belong in the world. These places evoke emotions that appeal to our sense of home and safety. + +As you can probably guess, this is critical in storytelling. They get you invested in the story while setting the groundwork for tone shifts in the narrative. This strategy is extra important in video games, where the player has the opportunity to directly interact with environments. + +For instance, if in a game you stumble across a village and bandits burn it down minutes after you arrive, you might feel some remorse, but you're relatively unattached. On the other hand, imagine you spend the first ten hours of the game living in the village, developing relationships with the villagers, doing difficult quests, and returning to the village for a break after each. Now when the bandits burn your village down, it hits hard, and it sets up for the "storm". + +This happens all the time in games. In _The Wind Waker_, you're forced to leave Outset Island to rescue your little sister, which then sets up the rest of the game. + +Games also use the opposite approach: the storm before the calm. In this case, the calm area becomes a reward for overcoming the storm. Think of virtually any JRPG — it probably has a boss before a humble town or village tucked in the woods. + +## Moments that stay with you + +I write frequently about the nostalgia value of games. What is it that the player will remember about a game in 1, 5, even 10 years? I believe it to be a crucial element to the experience, as games, much like a good book, can have a lasting impact on identity. + +When I was young enough to not know why my parents were fighting but old enough to master a controller, I played my first Zelda game, _Ocarina of Time_. I played it on a frayed, black leather couch in the basement. I remember how hot that couch would get in the summers, so hot that my siblings and I would place blankets on it before we played. When my parents fought, I would gather my three younger siblings, lead them to the basement, cuddle up on that disgusting black couch, and boot up _Ocarina of Time_ on the N64. And until my parents got tired or we all fell asleep, I would play Zelda with my siblings. I remember everything about that game. I will never forget, either, because it was such an experience that it was able to save me and my siblings from our troubled household. + +Part of what makes the best of video games stand out in our minds is the tension between chaos and tranquility; however, often the tranquility is what sticks in the memory when we play something we like. That's because good memories are sparked by this sense of bliss, especially when the bliss comes at the end of something or as a relief. + +Take _Dark Souls_ as an example. If you've finished any of the _Souls_ games, you're aware of how frustrating, even humiliating, they can be. And yet, they're decade-defining games. People remember the adversity they overcame. More so they remember every trap and monster waiting to jump you, and around which corners they lurk. They remember the harrowing music and endless fighting. But they also remember the relief of stumbling upon a fire, praising the sun, saving their game, and taking in their journey. + +Think back to your favorite games. What comes to mind? + +For me, I remember walking through the forest of Ellinia in Maplestory with my friends, mesmerized by the gentle melodies of Ellinia and the sprawling tree city. Or I think of the awe I felt stumbling into Stormwind City in _World of Warcraft_ as a tween (or the first time I flew over it). I think of stumbling onto Epona's Farm in _Ocarina of Time_ and hearing Epona play that sad, calming tune off in the center of the horses, this strange, cut-off farm in Hyrule Field where a family lives in peace. + +Seeing a trend? These memories are often tied to a location, one of positive significance, and typically peaceful. They're places where you feel safe in a game. **I call them _Save Spaces_.** + +## Drawing you in + +Now, save spaces do more than just establish memories. They also help you lose yourself in a game, which is to say, they make you feel like you're in the game. This phenomenon is commonly referred to in the gaming community as _immersion_. + +Save spaces are critical for establishing immersion. + +In _Nier: Automata_, a masterpiece of storytelling that I will refrain from spoiling out of respect for anyone who has yet to play it, the player is settled into a calm apocalypse. It's clear at the outset of the game that something major happened and that we aren't supposed to know or understand all of it. There's a magic in that — in a game's depth being larger than it actually is. That alone can suck a player in. I know I was. However, what makes this experience sink in is the paradoxical tranquility of it all. In this backward, seemingly demolished world, there is a resigned peacefulness. + +In either case, the world we're thrown into appears as a shell of what it once was, a patch of old forest that has been burned into an uncomfortable oblivion. But what makes us need to explore, to lose ourselves in this mysterious apocalypse, are the traces of life sprouting back into view. In _Nier_, it's the overgrown city ruins and small animals running around. In _Zelda_, it's the sun peeking over a faintly recognizable Hyrule. + +This tranquility makes us want to know what happened. It stirs up emotion, and it drives our interest in the game. That's the power of save spaces. It is the reason why certain games, certain moments even, will always sit comfortably in our memories, and it's why I will continue to write fondly about them. diff --git a/content/posts/snowglobe.md b/content/posts/snowglobe.md new file mode 100644 index 0000000..2774ef3 --- /dev/null +++ b/content/posts/snowglobe.md @@ -0,0 +1,60 @@ +--- +title: "Overengineering a Multiplayer Experience Instead of a Slide Deck" +topic: "Engineering" +date: "Dec 17 2025" +description: "Something joyful, with SvelteKit and Gleam." +--- + +A few weeks ago I had a weird dream. + +Pixelated avatar of Devon + +It was weird because it was 1.) a work dream (usually not my fave) and 2.) goofy as hell. + +In it, I was celebrating with my team in a cozy holiday app. I existed as a cursor, a retro/pixelated avatar version of myself, leaving notes in a virtual guestbook. When I woke up, I first decided I needed to spend less time on the internet. + +But shortly after that I decided it _was_ kind of a cozy dream, and my mind kept drifting to wanting to build a similar experience IRL. + +This year has been special for my team. We got acquired. There's been chaos and uncertainty, but also growth and camaraderie. We typically do a sort of "Year in Review" powerpoint thing, recapping highlights and all that. + +Between my desire to not write some slides and the nerd-sniping of my dream, I set to work trying to make something more fun than our usual powerpoint. + +> **Try it yourself** +> +> I open-sourced a demo version called [snowglobe](https://snowglobe.devon-wells.workers.dev/) that you can try right now (use party code `demo2025` to create a guest account). It's designed to be forked—all the content is config-driven. Check out the [behind-the-scenes page](https://snowglobe.devon-wells.workers.dev/behind-the-scenes) for a technical deep-dive or grab the [repo](https://github.com/devdumpling/snowglobe). + +## Play + +> "If you want creative workers, give them enough time to play." — John Cleese + +Lately I've been obsessed with this cute language, [gleam](https://gleam.run/). I like it for a lot of reasons, in particular the small surface area (you can learn it in an afternoon), excellent language server, and the fact that it compiles to Erlang (and JS lol). + +I didn't have any experience with the BEAM, and this felt like a good project to test things out. + +The architecture split naturally: SvelteKit on the front (auth, SSR, the timeline), Gleam for the "alive" stuff (realtime presence, cursor positions, reactions). SvelteKit I know well; it gets out of the way and lets me move fast. Gleam was the adventure. + +Why Gleam/Erlang for a side project? The BEAM (Erlang's virtual machine) was built for telecom—millions of tiny conversations happening simultaneously. That's exactly what realtime multiplayer feels like: cursors gossiping across screens, presence pinging every 50ms, reactions bubbling up etc. The actor model handles this elegantly (supposedly, again I'm new here hi). + +I didn't spend time stress testing it, but I was able to easily spin up 20-30 sessions happening at once locally on an M1 without my CPU breaking a sweat. Sure, 20-30 sessions is nothing, but it's cool that it "just worked" without me having to think about what happens when any of those sessions failed or whatever. + +On the frontend, Svelte 5 made the interactive bits easy to build. If you haven't spent much time in Svelte, I recommend giving it an honest try. There's lots of stuff I could talk about, but wanted to highlight the cursor physics: + +```javascript +// springs made easy a la Svelte +const springX = Spring.of(() => x, { stiffness: 0.12, damping: 0.7 }); +const springY = Spring.of(() => y, { stiffness: 0.12, damping: 0.7 }); +``` + +Those two lines smooth out the 50ms network updates into fluid motion. When you see someone else's cursor glide across the screen, that's spring physics interpolating between heartbeats. + +I sprinkled in features: Konami code triggers confetti, there's a cookie clicker for "liking" events (why not 🤷‍♀️), floating emoji reactions that drift upward and fade. Each one exists purely because it made me smile while building it. + +## Ship Your Silly Thing + +Was it overengineered? Absolutely. It was also super fun. + +Watching my teammates discover their pixelated avatars and trigger the Konami code made the whole thing worth it. After a year of hard work, it feels good to make something off-beat and mostly throwaway. + +Highly recommend. + +[Come play.](https://snowglobe.devon-wells.workers.dev/) diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..88e58f1 --- /dev/null +++ b/gleam.toml @@ -0,0 +1,19 @@ +name = "verso" +version = "0.1.0" +description = "Fullstack Gleam exploration for Verso" +licences = ["AGPL-3.0-or-later"] + +[dependencies] +argv = ">= 1.0.2 and < 2.0.0" +fio = ">= 1.2.1 and < 2.0.0" +gleam_stdlib = ">= 0.44.0 and < 2.0.0" +mork = ">= 1.12.0 and < 2.0.0" +nakai = ">= 1.1.2 and < 2.0.0" +taffy = ">= 1.0.2 and < 2.0.0" +str = ">= 2.1.1 and < 3.0.0" +mist = ">= 6.0.3 and < 7.0.0" +wisp = ">= 2.2.2 and < 3.0.0" +gleam_erlang = ">= 1.3.0 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.6.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..79c5cbb --- /dev/null +++ b/manifest.toml @@ -0,0 +1,49 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "casefold", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "casefold", source = "hex", outer_checksum = "86F8E4A12F441538D4DF9315A0584598F0C848678E87D13E25DDB43C61DB16E6" }, + { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, + { name = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" }, + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "fio", version = "1.2.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "fio", source = "hex", outer_checksum = "5D4A4B3855692BD9280824996AD8BC61DBAEEADBD7D68BF6FD08C8BD031510AB" }, + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, + { name = "gleam_stdlib", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "960090C2FB391784BB34267B099DC9315CC1B1F6013E7415BC763CEF1905D7D3" }, + { name = "gleeunit", version = "1.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "254B697FE72EEAD7BF82E941723918E421317813AC49923EE76A18C788C61E72" }, + { name = "glisten", version = "9.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "7795AA50830656F3A0316A6B26595F893C83272DA901B3405E31339CAA31A10B" }, + { name = "gramps", version = "6.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "D55636072DEE173F6586A5679D3C02EC7A0DE3F8646B78C351B72908FF223DF7" }, + { name = "houdini", version = "1.2.1", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "BC5F18CE5DD9686100229FE5409BDC3DD5C46D5A7DF2F804AD2D8F0DD6C5060E" }, + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, + { name = "mist", version = "6.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "1B07F321D5FA0CB162D81496F2DE96AEB6EF8980F4F38230A4CC3F849497E020" }, + { name = "mork", version = "1.12.0", build_tools = ["gleam"], requirements = ["casefold", "gleam_regexp", "gleam_stdlib", "splitter"], otp_app = "mork", source = "hex", outer_checksum = "9C08D94C80FE7DF25D4C5E2511390AEDF20DC3CFA110BC61DD23F4518172AC5C" }, + { name = "nakai", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "nakai", source = "hex", outer_checksum = "04C915DAEAE7DC66150E56B685EC4ABA80265F8C4095ED5ACB329B1140B980E7" }, + { name = "odysseus", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "odysseus", source = "hex", outer_checksum = "6A97DA1075BDDEA8B60F47B1DFFAD49309FA27E73843F13A0AF32EA7087BA11C" }, + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, + { name = "str", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "houdini", "odysseus"], otp_app = "str", source = "hex", outer_checksum = "641BBAE6BB781B9652EA0A47A8D06E536BF823397FD2BE4C5754B67E333C999E" }, + { name = "taffy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "taffy", source = "hex", outer_checksum = "DC425FDB4FABF49407E54840C7971AD167CA5B53D3119D06118F158D4DD6EDC6" }, + { name = "wisp", version = "2.2.2", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "5FF5F1E288C3437252ABB93D8F9CF42FF652CE7AD54480CFE736038DC09C4F22" }, +] + +[requirements] +argv = { version = ">= 1.0.2 and < 2.0.0" } +fio = { version = ">= 1.2.1 and < 2.0.0" } +gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } +gleeunit = { version = ">= 1.6.0 and < 2.0.0" } +mist = { version = ">= 6.0.3 and < 7.0.0" } +mork = { version = ">= 1.12.0 and < 2.0.0" } +nakai = { version = ">= 1.1.2 and < 2.0.0" } +str = { version = ">= 2.1.1 and < 3.0.0" } +taffy = { version = ">= 1.0.2 and < 2.0.0" } +wisp = { version = ">= 2.2.2 and < 3.0.0" } diff --git a/readme.md b/readme.md index b3c6027..1ce0ecf 100644 --- a/readme.md +++ b/readme.md @@ -1 +1,135 @@ -# verso +# Verso + +Verso is a fullstack Gleam exploration. The first working slice is a small static blog generator with a matching development server, built to test typed content loading, HTML rendering, URL generation, static output, and server-side routing in Gleam. + +## Current Status + +Implemented: + +- Markdown posts with YAML frontmatter in `content/posts/` +- Frontmatter parsing with `taffy` +- Markdown rendering with `mork` +- HTML page rendering with `nakai` +- Static site generation into `dist/` +- Wisp/Mist development server on `http://localhost:4000` +- RSS feed generation at `/feed.xml` +- Sitemap generation at `/sitemap.xml` +- Shared content/render pipeline for static build and dev server +- Parsed post dates with newest-first ordering +- Typed site metadata and route helpers +- Seed content migrated from the prior blog into plain Markdown + +Generated output directories are intentionally ignored: + +- `build/` +- `dist/` + +## Usage + +Install dependencies and compile: + +```sh +gleam build +``` + +Run the static site generator: + +```sh +gleam run -m blog build +``` + +This writes: + +- `dist/index.html` +- `dist/blog//index.html` +- `dist/feed.xml` +- `dist/sitemap.xml` +- `dist/static/*` + +Run the development server: + +```sh +gleam run -m blog serve +``` + +The server listens on `http://localhost:4000` and reloads posts from `content/posts/` on each request. + +Run tests: + +```sh +gleam test +``` + +Format, build, generate, and test: + +```sh +gleam format && gleam build && gleam run -m blog build && gleam test +``` + +## Content + +Posts live in `content/posts/*.md` and use YAML frontmatter: + +```md +--- +title: "Example Post" +topic: "Engineering" +date: "Apr 13 2026" +description: "A short description for indexes and feeds." +archive: true +--- + +Post body in Markdown. +``` + +Required fields: + +- `title` +- `topic` +- `date`, formatted as `Mon D YYYY` +- `description` + +Optional fields: + +- `archive`, defaults to `false` + +## Architecture + +Core modules: + +- `src/blog.gleam`: command entrypoint for `build` and `serve` +- `src/blog/build.gleam`: static output generation +- `src/blog/server.gleam`: Wisp/Mist development server +- `src/blog/content.gleam`: post loading, frontmatter validation, Markdown rendering, sorting +- `src/blog/render.gleam`: page, post, feed, and sitemap rendering +- `src/blog/site.gleam`: typed site metadata +- `src/blog/routes.gleam`: path and absolute URL helpers +- `src/blog/date.gleam`: date parsing and date sort helpers + +Static build and development server both use `blog/content` and `blog/render`, so generated output and served output stay aligned. + +## Routes + +The development server supports: + +- `/` +- `/blog/:slug` +- `/feed.xml` +- `/sitemap.xml` +- `/static/*` + +## Dependencies + +Primary libraries: + +- `taffy` for YAML frontmatter +- `mork` for Markdown parsing/rendering +- `nakai` for HTML generation +- `wisp` and `mist` for the development server +- `fio` for filesystem access +- `str` for HTML/XML escaping +- `gleeunit` for tests + +## License + +AGPL-3.0-or-later diff --git a/src/blog.gleam b/src/blog.gleam new file mode 100644 index 0000000..0702679 --- /dev/null +++ b/src/blog.gleam @@ -0,0 +1,19 @@ +import argv +import blog/build +import blog/server +import gleam/int +import gleam/io + +pub fn main() { + case argv.load().arguments { + ["build"] -> { + case build.run() { + Ok(count) -> + io.println("Built " <> int.to_string(count) <> " posts to dist/") + Error(message) -> panic as message + } + } + ["serve"] -> server.run(4000) + _ -> io.println("usage: gleam run -m blog ") + } +} diff --git a/src/blog/build.gleam b/src/blog/build.gleam new file mode 100644 index 0000000..e592cee --- /dev/null +++ b/src/blog/build.gleam @@ -0,0 +1,64 @@ +import blog/content.{type Post} +import blog/render +import blog/site as site_config +import fio +import fio/error +import gleam/list +import gleam/result + +const dist = "dist" + +pub fn run() -> Result(Int, String) { + use posts <- result.try(content.load_posts()) + let site = site_config.default() + use _ <- result.try(file(fio.delete_all(dist))) + use _ <- result.try(file(fio.create_directory_all(fio.join(dist, "blog")))) + use _ <- result.try( + file(fio.copy_directory("static", fio.join(dist, "static"))), + ) + + use _ <- result.try(write_posts(site, posts)) + use _ <- result.try(write_index(site, posts)) + use _ <- result.try(write_feed(site, posts)) + use _ <- result.try(write_sitemap(site, posts)) + + Ok(list.length(posts)) +} + +fn write_posts( + site: site_config.Site, + posts: List(Post), +) -> Result(Nil, String) { + list.fold(posts, Ok(Nil), fn(acc, post) { + use _ <- result.try(acc) + let directory = fio.join_all([dist, "blog", post.slug]) + let path = fio.join(directory, "index.html") + use _ <- result.try(file(fio.create_directory_all(directory))) + file(fio.write(path, render.post(site, post))) + }) +} + +fn write_index( + site: site_config.Site, + posts: List(Post), +) -> Result(Nil, String) { + file(fio.write(fio.join(dist, "index.html"), render.index(site, posts))) +} + +fn write_feed( + site: site_config.Site, + posts: List(Post), +) -> Result(Nil, String) { + file(fio.write(fio.join(dist, "feed.xml"), render.feed(site, posts))) +} + +fn write_sitemap( + site: site_config.Site, + posts: List(Post), +) -> Result(Nil, String) { + file(fio.write(fio.join(dist, "sitemap.xml"), render.sitemap(site, posts))) +} + +fn file(value: Result(a, error.FioError)) -> Result(a, String) { + value |> result.map_error(fio.explain) +} diff --git a/src/blog/content.gleam b/src/blog/content.gleam new file mode 100644 index 0000000..78d12f4 --- /dev/null +++ b/src/blog/content.gleam @@ -0,0 +1,110 @@ +import blog/date as post_date +import fio +import fio/error +import gleam/list +import gleam/option.{None, Some} +import gleam/order +import gleam/result +import gleam/string +import mork +import taffy + +pub type Post { + Post( + slug: String, + title: String, + topic: String, + published: post_date.PublishedDate, + description: String, + archive: Bool, + html: String, + ) +} + +const posts_dir = "content/posts" + +pub fn load_posts() -> Result(List(Post), String) { + use names <- result.try(file(fio.list(posts_dir))) + names + |> list.filter(fn(name) { string.ends_with(name, ".md") }) + |> list.sort(by: string.compare) + |> list.fold(Ok([]), fn(acc, name) { + use posts <- result.try(acc) + use post <- result.try(load_post(name)) + Ok([post, ..posts]) + }) + |> result.map(sort_posts) +} + +fn load_post(name: String) -> Result(Post, String) { + let path = fio.join(posts_dir, name) + use raw <- result.try(file(fio.read(path))) + let #(frontmatter, markdown) = mork.split_frontmatter_from_input(raw) + use metadata <- result.try(parse_frontmatter(path, frontmatter)) + + let slug = name |> string.drop_end(3) + use title <- result.try(get_string(metadata, "title")) + use topic <- result.try(get_string(metadata, "topic")) + use date_label <- result.try(get_string(metadata, "date")) + use published <- result.try(post_date.parse(date_label)) + use description <- result.try(get_string(metadata, "description")) + let archive = get_bool(metadata, "archive", False) + let html = + mork.configure() + |> mork.extended(True) + |> mork.heading_ids(True) + |> mork.parse_with_options(markdown) + |> mork.to_html + + Ok(Post(slug:, title:, topic:, published:, description:, archive:, html:)) +} + +fn sort_posts(posts: List(Post)) -> List(Post) { + posts + |> list.sort(fn(a, b) { + case post_date.descending(a.published, b.published) { + order.Eq -> string.compare(a.slug, b.slug) + order -> order + } + }) +} + +fn parse_frontmatter( + path: String, + frontmatter: String, +) -> Result(taffy.Value, String) { + case taffy.parse(frontmatter) { + Ok(value) -> + taffy.validate_unique_keys(value) + |> result.map_error(fn(_) { "duplicate frontmatter key in " <> path }) + Error(_) -> Error("invalid YAML frontmatter in " <> path) + } +} + +fn get_string(metadata: taffy.Value, key: String) -> Result(String, String) { + case taffy.get(metadata, key) { + Ok(value) -> { + case taffy.as_string(value) { + Some(text) -> Ok(text) + None -> Error("frontmatter key `" <> key <> "` must be a string") + } + } + Error(_) -> Error("missing frontmatter key `" <> key <> "`") + } +} + +fn get_bool(metadata: taffy.Value, key: String, default: Bool) -> Bool { + case taffy.get(metadata, key) { + Ok(value) -> { + case taffy.as_bool(value) { + Some(value) -> value + None -> default + } + } + Error(_) -> default + } +} + +fn file(value: Result(a, error.FioError)) -> Result(a, String) { + value |> result.map_error(fio.explain) +} diff --git a/src/blog/date.gleam b/src/blog/date.gleam new file mode 100644 index 0000000..430ff97 --- /dev/null +++ b/src/blog/date.gleam @@ -0,0 +1,58 @@ +import gleam/int +import gleam/list +import gleam/result +import gleam/string + +pub type PublishedDate { + PublishedDate(label: String, year: Int, month: Int, day: Int, sort_key: Int) +} + +pub fn parse(label: String) -> Result(PublishedDate, String) { + case string.split(label, on: " ") { + [month_name, day_text, year_text] -> { + use month <- result.try(parse_month(month_name)) + use day <- result.try(parse_int(day_text, "day")) + use year <- result.try(parse_int(year_text, "year")) + Ok(PublishedDate( + label:, + year:, + month:, + day:, + sort_key: year * 10_000 + month * 100 + day, + )) + } + _ -> Error("date must use format `Mon D YYYY`: " <> label) + } +} + +pub fn descending(a: PublishedDate, b: PublishedDate) { + int.compare(b.sort_key, a.sort_key) +} + +fn parse_int(value: String, name: String) -> Result(Int, String) { + value + |> int.parse + |> result.map_error(fn(_) { "invalid date " <> name <> ": " <> value }) +} + +fn parse_month(value: String) -> Result(Int, String) { + let months = [ + #("Jan", 1), + #("Feb", 2), + #("Mar", 3), + #("Apr", 4), + #("May", 5), + #("Jun", 6), + #("Jul", 7), + #("Aug", 8), + #("Sep", 9), + #("Oct", 10), + #("Nov", 11), + #("Dec", 12), + ] + + case list.find(months, fn(month) { month.0 == value }) { + Ok(month) -> Ok(month.1) + Error(_) -> Error("invalid date month: " <> value) + } +} diff --git a/src/blog/render.gleam b/src/blog/render.gleam new file mode 100644 index 0000000..6e7dec1 --- /dev/null +++ b/src/blog/render.gleam @@ -0,0 +1,149 @@ +import blog/content.{type Post} +import blog/routes +import blog/site.{type Site} +import gleam/list +import gleam/string +import nakai +import nakai/attr +import nakai/html +import str + +pub fn index(site: Site, posts: List(Post)) -> String { + page(site, "Blog", site.description, [ + html.section([attr.class("hero")], [ + html.p_text([attr.class("eyebrow")], "Fullstack Gleam Exploration"), + html.h1_text([], "A tiny static blog generator"), + html.p_text( + [], + "Markdown posts, YAML frontmatter, Nakai page shells, and deterministic static output.", + ), + ]), + html.section([attr.class("post-list")], posts |> list.map(post_card)), + ]) +} + +pub fn post(site: Site, post: Post) -> String { + page(site, post.title, post.description, [ + html.article([attr.class("post")], [ + html.a_text( + [attr.class("back-link"), attr.href(routes.home())], + "Back to all posts", + ), + html.header([], [ + html.p_text( + [attr.class("eyebrow")], + post.topic <> " / " <> post.published.label, + ), + html.h1_text([], post.title), + html.p_text([attr.class("description")], post.description), + case post.archive { + True -> + html.p_text( + [attr.class("archive-note")], + "Archived from the original wlls.dev blog.", + ) + False -> html.Nothing + }, + ]), + html.div([attr.class("prose")], [html.UnsafeInlineHtml(post.html)]), + ]), + ]) +} + +pub fn feed(site: Site, posts: List(Post)) -> String { + "\n" + <> "\n" + <> "" + <> xml_escape(site.title) + <> "\n" + <> "" + <> site.url + <> "\n" + <> "" + <> xml_escape(site.description) + <> "\n" + <> string.join(posts |> list.map(feed_item(site, _)), "") + <> "\n" +} + +pub fn sitemap(site: Site, posts: List(Post)) -> String { + "\n" + <> "\n" + <> "" + <> routes.absolute(site, routes.home()) + <> "\n" + <> string.join(posts |> list.map(sitemap_item(site, _)), "") + <> "\n" +} + +fn page( + site: Site, + title: String, + description: String, + children: List(html.Node), +) -> String { + html.Html([attr.lang("en")], [ + html.Head([ + html.title(title <> " - " <> site.title), + html.meta([ + attr.name("viewport"), + attr.content("width=device-width, initial-scale=1"), + ]), + html.meta([attr.name("description"), attr.content(description)]), + html.link([attr.rel("stylesheet"), attr.href(routes.stylesheet())]), + ]), + html.Body([], [ + html.header([attr.class("site-header")], [ + html.a_text( + [attr.class("site-title"), attr.href(routes.home())], + site.title, + ), + html.a_text([attr.href(routes.feed())], "RSS"), + ]), + html.main([], children), + html.footer_text( + [attr.class("site-footer")], + "Generated with Gleam by " <> site.author <> ".", + ), + ]), + ]) + |> nakai.to_string +} + +fn post_card(post: Post) -> html.Node { + html.article([attr.class("post-card")], [ + html.p_text( + [attr.class("eyebrow")], + post.topic <> " / " <> post.published.label, + ), + html.h2([], [html.a_text([attr.href(routes.post(post.slug))], post.title)]), + html.p_text([], post.description), + ]) +} + +fn feed_item(site: Site, post: Post) -> String { + "\n" + <> "" + <> xml_escape(post.title) + <> "\n" + <> "" + <> routes.absolute(site, routes.post(post.slug)) + <> "\n" + <> "" + <> routes.absolute(site, routes.post(post.slug)) + <> "\n" + <> "" + <> xml_escape(post.description) + <> "\n" + <> "\n" +} + +fn sitemap_item(site: Site, post: Post) -> String { + "" + <> routes.absolute(site, routes.post(post.slug)) + <> "\n" +} + +fn xml_escape(value: String) -> String { + str.escape_html(value) +} diff --git a/src/blog/routes.gleam b/src/blog/routes.gleam new file mode 100644 index 0000000..fa5e5b0 --- /dev/null +++ b/src/blog/routes.gleam @@ -0,0 +1,29 @@ +import blog/site.{type Site} + +pub fn home() -> String { + "/" +} + +pub fn post(slug: String) -> String { + "/blog/" <> slug <> "/" +} + +pub fn feed() -> String { + "/feed.xml" +} + +pub fn sitemap() -> String { + "/sitemap.xml" +} + +pub fn stylesheet() -> String { + static_asset("styles.css") +} + +pub fn static_asset(path: String) -> String { + "/static/" <> path +} + +pub fn absolute(site: Site, path: String) -> String { + site.url <> path +} diff --git a/src/blog/server.gleam b/src/blog/server.gleam new file mode 100644 index 0000000..7d9d6fa --- /dev/null +++ b/src/blog/server.gleam @@ -0,0 +1,82 @@ +import blog/content.{type Post} +import blog/render +import blog/site +import gleam/erlang/process +import gleam/list +import mist +import wisp.{type Request, type Response} +import wisp/wisp_mist + +const secret_key_base = "development-secret-key-base-development-secret-key-base-64-bytes" + +pub fn run(port: Int) -> Nil { + wisp.configure_logger() + + let assert Ok(_) = + handle_request + |> wisp_mist.handler(secret_key_base) + |> mist.new + |> mist.port(port) + |> mist.start + + wisp.log_info("Serving blog at http://localhost:4000") + process.sleep_forever() +} + +fn handle_request(req: Request) -> Response { + use <- wisp.rescue_crashes + use <- wisp.log_request(req) + use req <- wisp.handle_head(req) + use <- wisp.serve_static(req, under: "/static", from: "static") + + case wisp.path_segments(req) { + [] -> index_response() + ["blog", slug] -> post_response(slug) + ["feed.xml"] -> feed_response() + ["sitemap.xml"] -> sitemap_response() + _ -> wisp.not_found() + } +} + +fn index_response() -> Response { + case content.load_posts() { + Ok(posts) -> wisp.html_response(render.index(site.default(), posts), 200) + Error(_) -> wisp.internal_server_error() + } +} + +fn post_response(slug: String) -> Response { + case content.load_posts() { + Ok(posts) -> { + case find_post(posts, slug) { + Ok(post) -> wisp.html_response(render.post(site.default(), post), 200) + Error(_) -> wisp.not_found() + } + } + Error(_) -> wisp.internal_server_error() + } +} + +fn feed_response() -> Response { + case content.load_posts() { + Ok(posts) -> + wisp.response(200) + |> wisp.string_body(render.feed(site.default(), posts)) + |> wisp.set_header("content-type", "application/rss+xml; charset=utf-8") + Error(_) -> wisp.internal_server_error() + } +} + +fn sitemap_response() -> Response { + case content.load_posts() { + Ok(posts) -> + wisp.response(200) + |> wisp.string_body(render.sitemap(site.default(), posts)) + |> wisp.set_header("content-type", "application/xml; charset=utf-8") + Error(_) -> wisp.internal_server_error() + } +} + +fn find_post(posts: List(Post), slug: String) -> Result(Post, Nil) { + posts |> list.find(fn(post) { post.slug == slug }) +} diff --git a/src/blog/site.gleam b/src/blog/site.gleam new file mode 100644 index 0000000..3375d4a --- /dev/null +++ b/src/blog/site.gleam @@ -0,0 +1,12 @@ +pub type Site { + Site(title: String, url: String, description: String, author: String) +} + +pub fn default() -> Site { + Site( + title: "Verso Fullstack Gleam Blog", + url: "https://wlls.dev", + description: "A static blog generated by the Verso fullstack Gleam exploration.", + author: "Devon Wells", + ) +} diff --git a/src/verso.gleam b/src/verso.gleam new file mode 100644 index 0000000..976a7f1 --- /dev/null +++ b/src/verso.gleam @@ -0,0 +1,5 @@ +import blog + +pub fn main() { + blog.main() +} diff --git a/static/images/avatars/dev.webp b/static/images/avatars/dev.webp new file mode 100644 index 0000000000000000000000000000000000000000..c6111c620aaa131ab1bee9b43d5ab81a6b99df29 GIT binary patch literal 2312 zcmWIYbaP|iWMBw)bqWXzu<-fJ!N8#Z|6sj9`vSE|YoD=iV$iyyWK=puB;&^RFr^k0;2vAG>&W>HJlPk_C46?cotq zR^dDnIU_~)lU2&ODvrQ^N6(qo&+f_LBlZq!k_W7%j?78jewm+5GaZeYV ziVJ7Du=mfkU+3%MyqnG&pNu-Ttw>{^>5{UXhjM1|SL`oE{lED?+w((&G;>Ywt6%5s zzCD>0a9jFBk>|Bd|2EIJvFV>|D0=yQa`nEyy07hjXZXJCd=mIVT4>tktzY>bZd$MV zZr0N~A@>fy+r4+@7wsLddoj!bN_zRz3ab3{1Um8ydmW0qs=}qKh`R(JbtWGrQomc zlk?4ACB7)H-P3mWeAyb->SZZBNmHV~ZB71uaG!d`o?o8>YkITh9e($^&c;UX*T+Zi zn)gqO*S@d#ydtUN<0HkB`jaf)+S;%myVcQrcH-LqXWlFCt2?$gVuPjZq(4jN|1lSv zbmU<11(^deIyckb{<2)R{%`H8{{P!lU%Wk<6k{)~{fybN$2s)nmXAr6Aye1u*Z$0( zBhKyVmBe-7xZL%+XGQat`0csAti{4$egE%<_3O7@pa08l<2U2^o0mMDDZFWa@~@dH zzcmBhdRd&`d8 zX|H>>-(a3f@|r)ac@tA)%BJ{_LEr@isK?M=gJ&o}(O{l91VjN&;TzK4D~ zzSO3ht6I%%Py74YkNvh!In`23FN%BqmAqtE^Y+s1g!!J5zl|B6&DQ(*ZT^W`jh#G` zwp=_PG2v&#o8K}Mmh@!G?qAw`mwV~7fSvRU@QcEyd-?_Z4ti)GCU!|I&wx!=^^ zccbIv-;~|!RZ^|jsg%~__FK0fx^CBYYX3b}`RcB%pM>|lZ*NgPzAWc`)+@J5$LyDh zhI{Xp_}X9ivtrU2@tp5A!gBp8E1N6RJq&Mz+5DR$mcO~a=WHEc=)Q#CI=lU<&#`|l zIHzapc`W+x!`*7qwsoJ_Zts7#MgP&UojIqcdmj^;eEXSQ$@%-&tE74F1U-_zws2LR ziv6lr0ihOUv;X|m^ou#O?RNE)#S@C13U9ofvhh>9z{$;P-juGd|F&Yrq>rtF*+(5G z{bNee`E5UO^@r+T5|htcwZG!xJS_g-sQyc3;e+Iu8#gBR=4`epwn-N(|Npgi>908x zb1qxP{FoK0WA^6H6?=XW=Q}b}_^#cHVVl;TRPFsfRQ3H|`SsE_&#$_0yYcC=w^^P4 z*S)$?t?|U>P2u7?n?K6e1%95N)3dQ==XTrgQ>1U?c{j~J{2?&3Uc#+K)Vf^M+^zL| zWMa1eho2j44wOajXZ@j*nwMEDl6|z#J(2YZ$F{qZYMbBk-{pJl7<8-x9A}S~T&DLsS&q?2nTh;5H>jUoZQ~Z?6NsN@T`+%gdkSKC_&mTIRXqCAs$- zpBHS{IoIyn!W-Aq;^)`#*F28jSG@Mov$>z-Hr4;1yvBD*&tZ1YJ6ZMLj!aI`bDsAz z#entavH6bKiNoR%du$u~?)SeRIto?ryD9#!x%%sTjg93p<>K`c^Csk} z9G_AAO4#z4$o4eV`HJ_yyVOnJQt_nn@@-DfTSwMMpN&27b;`HTGnZU_rW_iYEG%zc z_dZW+zGCw0tXIodJ>&oOFMB7CkXH`FI-M60QwE6l8dZANKS4{dM7~Jsh zURGVwxy@4V_i)c|uT_yZ=x6%FP%EIP+PC}i)wO>*_s1W9CbjHNNB-SR`@dS>w|;y6 zmHXbeIR|3alpXo{e8-n)C%FW@-~OtFkNuMOJ0Fse+xASA-(We@s#R}F)u*pwdXyWy zp!<{rGi$&Kh>l2F=s&pE5rC_UC2pgXOEXDc^f?f4+se!Tg;XbN*eQ`Qo`v z|Jmjxa{1Zz#oJOClaKV*uy5J;NcY|Ua1H)T8^pHfscg^Y%dAbek1UR|pX5K|zV*tp zM^7`p`OdWAx#hR^_j@}29NKek>4dFcQhT@Or~FO7ANlvJbMN%*XY6suw`!PAs#-Ub zyCS#v0=uX6y=(PxkE1peD_yJqR<2vd(Ky1-qUcu*^6rq?9)x$y+4tD1+Zq4YP1$p5 z;*#2JA?w;zN}s&1PqE2UslD+$aQ)vmm(#sp+?UOrBa~EhcmAqPk8}T~-@o=r<)>>= z&OVQ%_e=Nu+dMhj@T-C)+rInau6zmik0https://wlls.dev/blog/snowglobe/") +} + +pub fn renders_sitemap_with_absolute_urls_test() { + let assert Ok(posts) = content.load_posts() + let sitemap = render.sitemap(site.default(), posts) + assert string.contains(sitemap, "https://wlls.dev/") + assert string.contains(sitemap, "https://wlls.dev/blog/snowglobe/") +} + +pub fn renders_post_with_published_label_test() { + let assert Ok(posts) = content.load_posts() + let assert Ok(post) = + list.find(posts, fn(post) { post.slug == "ai-reflections-fatigue" }) + let page = render.post(site.default(), post) + assert string.contains(page, "Engineering / Apr 13 2026") +}