I'm a crayon
imacrayon.com.web.brid.gy
I'm a crayon
@imacrayon.com.web.brid.gy
Work by Christian Taylor. An artist & full stack developer based in Wichita, KS.
Cranking My Website Up to Eleventy
At about the same time I started a design refresh on this website, Zach Leatherman launched WebC, it's a small compiler used to author websites with single file components. I'd been feeling NPM-dependency-hell-burnout lately and was looking for a way to simplify my website's tech stack and return to good 'ol plain HTML & CSS; Eleventy paired with WebC felt like just enough abstraction without straying to far away from the web platform. So I did what many (all?) developers do with their personal site: I went all-in, scrapped everything, and rebuilt it all from scratch. I cranked my website up to Eleventy! The numbers all go to eleven...It's not ten. You see, most blokes will be playing at ten. ## Eleventy is fast and light Eleventy has a small footprint, currently the `node_modules` folder for this website, including four plugins, totals 34MB. Before now I wasn't convinced a Node project could be that small. In comparison, the `node_modules` for my old Jigsaw-powered blog weighed in at 190MB. Similarly, Next.js and Gatsby base installs start at a chonky 230MB and 368MB respectively. While getting up and going with Eleventy is fast, (you only need an index.html file to start) it's build time is also among the fastest. Recent benchmarks show that it outperforms most other static site generators when transforming markdown files into HTML. ## Organize code with WebC WebC provides is a simple way to co-locate your HTML, CSS, and JavaScript. Here's an abbreviated example of the intro component that appears on my new homepage: <figure> <img src="/img/christian-taylor.jpg" alt="Christian Taylor's mug shot" width="64" height="64"> <figcaption> <h1>Christian Taylor</h1> <p>Co-Founder of Moonbase Labs</p> </figcaption> </figure> <p>I’m a full stack developer...</p> <style webc:scoped> :host { display: block; padding-block: 2.5vh; } :host figure { display: flex; align-items: center; gap: 1rem; } :host img { border-radius: 100%; } ... </style> Notice the `webc:scoped` attribute on the `<style>` tag, it instructs the WebC parser to replace `:host` with a randomly generated CSS class so that all the styles are scoped to only the elements within the component. As another bonus, all of the `<style>` and `<script>` tags in all of the components on the page are bundled, minified, and injected right into the `<head>` of the page, so your website is optimized with critical CSS out of the box. ## View the source You can view all the source code for this site on GitHub.
imacrayon.com
February 17, 2026 at 10:07 PM
Adding Cloudflare to a Laravel Forge Site
I manage several websites hosted on Digital Ocean through Laravel Forge. Recently some unusual traffic on one of these websites prompted me to setup some Cloudflare security features. Cloudflare required that I migrate my website's DNS from Digital Ocean onto their platform, and this required some additional tweaks to settings within Laravel Forge and Digital Ocean. I'm going to document my migration process here for future me. ## DNS Migrating the DNS settings to Cloudflare is straight forward. Cloudflare is able to scan your domain and pull in all of the existing DNS settings that are already setup on Digital Ocean. ## SSL After migrating the DNS, you need to create a new TLS certificate signed by Cloudflare. Without this certificate the Digital Ocean server fails to respond to requests forwarded from Cloudflare. In the Cloudflare dashboard navigate to **SSL/TSL > Origin Server**. From this page you can generate a new certificate for your domain. Once you have a new certificate, head over to Laravel Forge. Navigate to the website's dashboard, then go to the **SSL** page, and click **Install Existing** under the New Certificate panel. Copy and paste the certificate information from Cloudflare into the Private Key and Certificate fields in Forge. At this point the website should be ready to go. The last step is to add your SSL certificate to any Digital Ocean Spaces CDNs your might have configured. ## Spaces CDN On the Settings page for your Digital Ocean Space, select **Edit > Add a new certificate**. Within the settings modal select the **Bring your own certificate** tab. From here you can add your certificate settings just like you did in SSL step above.
imacrayon.com
February 17, 2026 at 10:07 PM
MLK Day
I came across a speech Dr. Martin Luther King Jr. gave at an annual conference for the American Psychological Association in 1967. I was struck by just how relevant this passage is right now: > Urban riots must now be recognized as durable social phenomena. They may be deplored, but they are there and should be understood. Urban riots are a special form of violence. They are not insurrections. The rioters are not seeking to seize territory or to attain control of institutions. They are mainly intended to shock the white community. They are a distorted form of social protest. The looting which is their principal feature serves many functions. It enables the most enraged and deprived Negro to take hold of consumer goods with the ease the white man does by using his purse. Often the Negro does not even want what he takes; he wants the experience of taking. But most of all, alienated from society and knowing that this society cherishes property above people, he is shocking it by abusing property rights. There are thus elements of emotional catharsis in the violent act. This may explain why most cities in which riots have occurred have not had a repetition, even though the causative conditions remain. It is also noteworthy that the amount of physical harm done to white people other than police is infinitesimal and in Detroit whites and Negroes looted in unity. > > A profound judgment of today's riots was expressed by Victor Hugo a century ago. He said, 'If a soul is left in the darkness, sins will be committed. The guilty one is not he who commits the sin, but he who causes the darkness.' > > The policymakers of the white society have caused the darkness; they create discrimination; they structured slums; and they perpetuate unemployment, ignorance and poverty. It is incontestable and deplorable that Negroes have committed crimes; but they are derivative crimes. They are born of the greater crimes of the white society. When we ask Negroes to abide by the law, let us also demand that the white man abide by law in the ghettos. Day-in and day-out he violates welfare laws to deprive the poor of their meager allotments; he flagrantly violates building codes and regulations; his police make a mockery of law; and he violates laws on equal employment and education and the provisions for civic services. The slums are the handiwork of a vicious system of the white society; Negroes live in them but do not make them any more than a prisoner makes a prison. Let us say boldly that if the violations of law by the white man in the slums over the years were calculated and compared with the law-breaking of a few days of riots, the hardened criminal would be the white man. These are often difficult things to say but I have come to see more and more that it is necessary to utter the truth in order to deal with the great problems that we face in our society. > > Dr. Martin Luther King Jr., 1967 This week I'm reminding myself to take time to stop and listen. You can read the full speech here.
imacrayon.com
February 17, 2026 at 10:07 PM
Subdomain Redirects in Laravel Forge
I had to go through some trial and error to get this working so I'm writing it down. My goal was to permanently redirect a domain like `beta.imacrayon.com` to `imacrayon.com` in Laravel Forge. In order to properly redirect `https://beta.imacrayon.com`. I generated a new LetsEncrypt certificate with the following domains: imacrayon.com,www.imacrayon.com,beta.imacrayon.com Next I added two new server blocks to the top of my nginx config: server { listen 80; listen [::]:80; server_name beta.imacrayon.com; return 301 https://imacrayon.com$request_uri; } server { listen 443 ssl http2; listen [::]:443 ssl http2; # PASTE YOUR SSL DIRECTIVES HERE server_name beta.imacrayon.com; return 301 https://imacrayon.com$request_uri; } The second server block requires you to add the SSL directives for the LetsEncrypt certificate generated in the first step. You should be able to find these directives inside your existing nginx config. They’ll probably be labeled with a comment like `# FORGE SSL (DO NOT REMOVE!)` and look like this: # FORGE SSL (DO NOT REMOVE!) ssl_certificate /etc/nginx/ssl/imacrayon.com/xxx/server.crt; ssl_certificate_key /etc/nginx/ssl/imacrayon.com/xxx/server.key; ssl_protocols TLSv1.2; ssl_ciphers ...; ssl_prefer_server_ciphers on; ssl_dhparam /etc/nginx/dhparams.pem; That’s it! After saving your config `http://beta.imacrayon.com` and `https://beta.imacrayon.com` should now redirect to `https://imacrayon.com`. Browsers will cache 301 redirects. So when testing these new redirects you may need to enable the “Clear cache” option in your browser's Developer Tools, it’s usually located under the “Network” tab.
imacrayon.com
February 17, 2026 at 10:07 PM
Responsive Images in Eleventy Markdown
I added image support to my Eleventy website. I was looking for a solution that would automatically take a lot of the grunt work out of preparing images for the web. When it comes to images, there's actually a lot to consider: * You should serve images with the correct dimensions so they don't get distorted * You should use `srcset` & `sizes` attributes to load different sized images based your user's viewport * You should serve images in various formats (jpeg, avif, webp) so that modern browsers can load the page faster * You should support lazy loading and async decoding to improve page speed In addition to these technical concerns I wanted a solution that was easy to implement and would support adding captions to my images. I also wanted all of these features to be available to use in both my Eleventy templates and plain markdown files. I settled on using theses two plugins that take care of the heavy lifting: 1. eleventy-img provides all of the responsive image markup 2. markdown-it-image-figures adds optional caption support to Markdown images Here's how I stung all the configuration together in my `eleventy.config.js` file: const markdownItFigures = require('markdown-it-image-figures') const pluginImage = require('@11ty/eleventy-img') // Shared image configuration for templates & markdown const image = { path: (src) => src.replace('/img', `${__dirname}/_includes/img`), // Source directory options: { widths: [686, 1576], formats: ['avif', 'jpeg'], outputDir: './_site/img' // Output directory }, attrs: { sizes: 'calc(100vw - 2rem)', loading: 'lazy', decoding: 'async' } } // Add an Eleventy template shortcode const responsiveImage = async function (src, alt = '', widths = null) { image.options.widths = widths || image.options.widths const metadata = await pluginImage(image.path(src), image.options) return pluginImage.generateHTML(metadata, { alt, ...image.attrs }) }) eleventyConfig.addAsyncShortcode('image', responsiveImage) // Add markdown support eleventyConfig.amendLibrary("md", markdown => { // Transform image markdown into responsive image markup markdown.renderer.rules.image = function (tokens, idx) { const token = tokens[idx] let src = image.path(token.attrGet('src')) const alt = token.content pluginImage(src, image.options) const metadata = pluginImage.statsSync(src, image.options) return pluginImage.generateHTML(metadata, { alt, ...image.attrs }, { whitespaceMode: "inline" }) } // Add support for image captions markdown.use(markdownItFigures, { figcaption: 'title', }) }) This configuration assumes that you are storing your source images in `_includes/img` and that the resized and compressed images will be output to `_sites/img`. I wanted to author my image markup as if the image `src` was relative to the final output directory, so I included this line that rewrites the incoming image path to the correct source directory: src.replace('/img', `${__dirname}/_includes/img`) With all that setup out to the way, you can use images in Markdown like this: !My image alt text which will generate HTML like this: <figure> <picture> <source type="image/avif" srcset="/img/vGjvxtLm9u-686.avif 686w, /img/vGjvxtLm9u-1576.avif 1576w" sizes="calc(100vw - 2rem)"> <source type="image/jpeg" srcset="/img/vGjvxtLm9u-686.jpeg 686w, /img/vGjvxtLm9u-1576.jpeg 1576w" sizes="calc(100vw - 2rem)"> <img alt="My image alt text" loading="lazy" decoding="async" src="/img/vGjvxtLm9u-686.jpeg" width="1576" height="1576"> </picture> <figcaption>My optional caption for this image.</figcaption> </figure> ...and the same image markup can be generated within an Eleventy template like this: <figure> {% image "/img/my-image.jpg", "My image alt text", "calc(100vw - 2rem)" %} <figcaption>My optional caption for this image.</figcaption> </figure>
imacrayon.com
February 17, 2026 at 10:06 PM
2023 Recap
* Moonbase Labs stuck around for another year, work was steady. * This was my first year in a while that I've only worked for a single organization. Last year Friendship Lamps by Filimin sold to new owners and it felt like a good time to sunset my work on that project. * I built an electric vehicle fleet management tool for Lightning eMotors, we've already seen a few competitors in the industry copy the same look and feel. * I mostly stayed off social media again except Twitter, I'm probably too addicted to Twitter right now. * I wrote 3 blog posts. That's better than last year, but still bad. * I started talking about Alpine AJAX; I even designed and built a documentation site for it. The repository now has about 160 stars and it's getting some recognition on Twitter and in the Django community. * I maintained a vegetarian diet. * I worked out regularly all year and gained another 10 pounds. * I attended PHP[tek] in Chicago, IL; my very first tech conference. * I visited Nashville, TN for the first time while attending Laracon US; also my first Laracon. * I met some of my web development heros. * I listened to a lot of Ratboys and The Weakerthans. * I discovered music by Imani Graham, Jungle, Petey, Sampha, and Samia. * I saw Jeff Rosenstock live for the first time in Tulsa, OK. * I proposed to Carmen in the Flint Hills on Christmas Eve Eve.
imacrayon.com
February 17, 2026 at 10:06 PM
Build a Quick & Easy Instant Search User Interface using Alpine AJAX & Laravel
I’m going to walk you through how to easily add an instant search filter in your Laravel apps. When I say “instant search” I mean a text input that filters a list of results as you type. In this tutorial we’ll build out a basic contact list and then allow a user to search for a contact by name or email. We'll build the "search as you type" behavior, without writing any JavaScript, using the Alpine AJAX library. This UI pattern is one of my favorite examples that demonstrates the power and simplicity of Alpine AJAX. ## The Data You can start with a fresh Laravel install, get a database ready to go, then run `php artisan migrate` in the terminal to generate the “users” table. To ensure that you’ve got user data to work with, you can run `php artisan tinker` to enter the Tinker CLI, then `User::factory()->count(20)->create();` to generate 20 users in your database. ## The View Since we're focusing on the user interface, let's start with the view so we can get a sense of how things will look and feel. We'll create a Blade template at `resources/views/contacts.blade.php`. It’s going to be a basic page with a search form followed by a list of contacts. I’m leaving CSS styling out of this tutorial so that we can focus on writing good markup, style things however you’d like. <!-- resources/views/contacts.blade.php --> <!doctype html> <html> <head> <title>Contacts</title> </head> <body> <h1>Contacts</h1> <form role="search" aria-label="Contacts"> <label for="term">Search</label> <input type="search" id="term" name="term"> <button>Submit</button> </form> <h2>Results</h2> <ul role="list"> @foreach($contacts as $contact) <li>{{ $contact->name }} – {{ $contact->email }}</li> @endforeach </ul> <body> </html> We've left the `action` and `method` attributes off of the search `<form>` so by default the form will issue a `GET` request to the current URL. The search `<input>` has the `name` **“term”** so that we can access the submitted search team on the backend. Next, we'll scaffold out some backend logic for this form. ## The Route Now let's create a new route at `/contacts`. Our route logic will check if the incoming request contains a value for **“term”** , if so, it’ll query the “users” table for a record with a **“name”** or **“email”** that contains the search term: // routes/web.php use App\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; Route::get('/contacts', function (Request $request) { $contacts = User::when($request->term, function ($query, $term) { $query->where(function ($query) use ($term) { $query->where('name', 'like', "%{$term}%")->orWhere('email', 'like', "%{$term}%"); }); })->get(); return view('contacts', [ 'contacts' => $contacts, ]); }); Note that this is a simple search implementation just for demonstration purposes. You’d probably be better off using something like Laravel Scout for database searches, but that’s beyond the scope of this tutorial. At this point you should be able to navigate to `/contacts` in your browser, submit the search form, and see that the page reloads with an updated list of contacts. We've got our basic search form working! Now we can layer on extra features to make it feel really good to use. ## The Interaction It’s time to enhance our search form so that we get instant results as we type. This is where Alpine AJAX shines. Alpine AJAX is a small Alpine.js plugin that provides an easy way to make AJAX requests and render content on the page. First we’ll get Alpine and Alpine AJAX installed; we’ll add two script tags in the page `<head>`, but you can also install these libraries through NPM if you’d like: <!-- resources/views/contacts.blade.php --> <head> <title>Contacts</title> <script defer src="https://cdn.jsdelivr.net/npm/@imacrayon/alpine-ajax@0.3.0/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.11.1/dist/cdn.min.js"></script> </head> Next, let’s add a few attributes to our search elements: <!-- resources/views/contacts.blade.php --> <h1>Contacts</h1> <form role="search" aria-label="Contacts" x-init x-target="contacts"> <label for="term">Search</label> <input type="search" id="term" name="term" @input.debounce="$el.form.requestSubmit()"> <button x-show="false">Submit</button> </form> <h2>Results</h2> <ul id="contacts"> @foreach($contacts as $contact) <li>{{ $contact->name }} – {{ $contact->email }}</li> @endforeach </ul> Here's a breakdown of the attributes we've added: 1. `x-init` on the search form initializes our Alpine component. 2. `id="contacts"` on the list of contacts allows our search form the target the list. 3. `x-target="contacts"` on the search form changes the behavior of the form: When it is submitted an AJAX request is issued to `/contacts` and the updated `<ul id="contacts">` returned in the response replaces the existing contact list on the page. 4. `@input.debounce` on the search input automatically submits the search form when the value of the input is changed. 5. Finally, we’ve added `x-show="false"` to the search form’s submit button. This is a small progressive enhancement that will ensure the search form stays usable with or without JavaScript. When JavaScript is loaded, the button is hidden, but if JavaScript fails to load, the button stays on the page and provides a way for the user to manually submit a search term. That’s it! With the new Blade markup in place, refresh the page (make sure to clear out the `?term` query string in the URL if it's there). Now you should see the contact list magically update as you type. Check out the Alpine AJAX examples page to see more UI examples in action. It’s wild to see all the things you can accomplish with such a small JavaScript library.
imacrayon.com
February 17, 2026 at 10:06 PM
2024 Recap
* Moonbase Labs shrunk to two people. I'm both uncomfortable and excited about what next year will bring. * Twitter imploded this year and I've become more active on Bluesky. * I only wrote one blog post this year - yikes - I really was planning on writing more. * I published my first and only YouTube tutorial; it has earned 2700 views and I now have 101 subscribers. * I continued to maintain Alpine AJAX; it now has 687 stars (+527 from last year) on GitHub. The project has gained a few new contributors and some positive mentions on Reddit. * I maintained a vegetarian diet. * I lifted at least twice a week all year; I hit a new personal deadlift record of 305lbs. * I attended my first MicroConf in Atlanta, GA, and my second Laracon US in Dallas, TX. * At Laracon, Nic and I won $1,500 playing Pyramid Scheme, a game created by the Thunk team for Laracon attendees. * I made a painting for Carmen to hang in her office. * I listened to a lot of Bleachers, Jean Dawson, and Touché Amoré. * I discovered music by Doechii (She wrote a song about my car), Mk.gee, Friko, Little Simz, and rediscovered Fred, Again. * I saw Bleachers and The Get Up Kids live for the first time, and Jeff Rosenstock a second time. (Also - Casey, Underoath, Pool Kids, and Morpho) * I was entertained by the A$AP Rocky - Tailor Swif music video. * Nic and I played a lot of Abiotic Factor. * I voted. * Carmen and I got married! * I spent a week in Cancun with my whole family and the new wife.
imacrayon.com
February 17, 2026 at 10:06 PM
How I Built the "Now Playing" Component on My Website
The "Now Playing" component on my homepage features the last five jams I've been jamming to and updates in real-time as I play music. Here's how I made it and how you can build one for yourself: I listen to music mostly through Spotify, but the Spotify API requires a complex OAuth authentication process. To make things easier I created a Last.fm account and setup Spotify Scrobbling from Last.fm Settings page. Authenticating with the Last.fm API is very simple, you only need to register an API application and you're all set with an API token. Since the Last.fm API key is private, we can’t just fire off an API request in the browser, instead we've got to make API requests in a protected environment where our API credentials won’t be exposed to website visitors. Let's create a serverless function for this task. Digital Ocean, Netlify, or Cloudflare Workers all offer free serverless functions. I already have a lot of projects setup on Digital Ocean so it just made sense to stick with them. The function retrieves the four most recently played tracks from Last.fm and creates HTML snippets for each song. Note that you'll need to replace `YOUR_LAST_FM_USERNAME_HERE` and `YOUR_LAST_FM_API_KEY_HERE` with your actual credentials: const USERNAME = 'YOUR_LAST_FM_USERNAME_HERE' const API_KEY = 'YOUR_LAST_FM_API_KEY_HERE' const API_URL = 'https://ws.audioscrobbler.com/2.0/' function trackHtml(track, lazy = true) { return `<a href="${track.url}" class="track"> <img ${lazy ? 'loading="lazy"' : ''} width="64" height="64" src="${track.image[2]['#text']}" alt=""> <div> <p>${track.name}</p> <p>${track.artist['#text']}</p> </div> </a>` } function main() { return fetch(`${API_URL}?method=user.getrecenttracks&user=${USERNAME}&limit=4&api_key=${API_KEY}&format=json`) .then(response => response.json()) .then(json => json.recenttracks.track) .then(tracks => { let body = '' if (tracks.length) { body = `${trackHtml(tracks[0], false)} <details> <summary>Recently played</summary> <ul role="list"> <li>${trackHtml(tracks[1])}</li> <li>${trackHtml(tracks[2])}</li> <li>${trackHtml(tracks[3])}</li> </ul> </details>` } return { body } }) } So with a serverless function in place we can build a reusable web component that to display the "Now Playing" information on any web page. Make sure to replace `API_URL_ENDPOINT` with the actual URL of your serverless function: // now-playing.js window.customElements.define('now-playing', class extends HTMLElement { connectedCallback() { fetch(`API_URL_ENDPOINT`) .then(response => response.text()) .then(html => { if (html) { this.innerHTML = html } }) } }) Simply add a `<now-playing>` element to your HTML wherever you want the "Now Playing" section to appear. Any child elements will act as a "loading" state for the component, here's how the component on my homepage is structured: <now-playing> <div class="track"> <img src="" alt="" width="64" height="64"> <div> <p>Fetching track...</p> <p>Fetching artist...</p> </div> </div> <details> <summary>Recently played</summary> Fetching playlist... </details> </now-playing> Don't forget to style it with a little CSS to make it look great!
imacrayon.com
February 17, 2026 at 10:06 PM