More from noahbuscher.com
So you've set out to create a new portfolio for yourself. You start gathering inspiration from platforms like Godly and Minimal Gallery, draw some rectangles in Figma, and open your text editor. You pause. There's thousands of ways to build your website, how do you decide which to go with? You want your website to be beautiful for the users, but you also want to be able to quickly add new posts and case studies. You decide you want to have a dynamic website (good choice!), but the dozens of CMS options weigh on you. Do you pick headless or full-stack? What if you just make a theme? So many choices. I was faced with the same dilemma a few months ago. I finally had the motivation to create a new personal website, but wasn't sure where to start. I decided to keep things simple, I'd write it with a library I knew very well: React. That only solved half of the equation, however. When it came time to decide how to power the dynamic content on the site, I knew I wanted it to be free, easy to use, and open source. A static site powered by Next.js and markdown was the obvious choice. View the source on GitHub. To get started on creating your portfolio, run the following commands. I'm using pnpm, but feel free to use yarn or npm! pnpm dlx create-next-app@latest --typescript cd into your new project and run the following commands to install the necessary packages and bootstrap Tailwind: pnpm install dayjs pnpm install gray-matter react-markdown tailwindcss postcss autoprefixer -D pnpm dlx tailwindcss init -p That's a lot of packages. Let's break them down... gray-matter will be used to parse "front-matter" from our markdown files (we'll read more about this later!) react-markdown is a React component to render markdown content tailwindcss postcss autoprefixer are requirements for installing Tailwind for Next.js Phew! Let's continue our setup by finishing the Tailwind install... Add the following to the module.exports in the generated tailwind.config.js file: content: [ "./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}", "./layouts/**/*.{js,ts,jsx,tsx}", "./utils/**/*.{js,ts,jsx,tsx}" ], To bring the styles into your project, add the following to the styles/globals.css file: @tailwind base; @tailwind components; @tailwind utilities; Great! Finally, let's create our new directories by running the following in the root: mkdir _projects mkdir public/projects mkdir layouts mkdir components mkdir utils We're finally ready to start coding! To get started, create a new layout file, ProjectCard.tsx in the components directory. This component will be displayed for each project on the home page's grid. In components/ProjectCard.tsx add: import React from "react"; import Image from "next/image"; import Link from "next/link"; const ProjectCard = ({ title, description, slug, photo, }: { title: string; description: string; slug: string; photo: string; }) => ( <Link href={`/project/${slug}`}> <div className="flex-1 flex flex-col gap-2"> <Image src={photo} alt={title} width="0" height="0" sizes="100vw" className="w-full h-full aspect-square object-cover" priority /> <h3 className="text-black font-bold text-xs">{title}</h3> <p className="text-gray-500 text-xs">{description}</p> </div> </Link> ); export default ProjectCard; Now we need a place to display all of our project cards. Create a new layout called Grid.tsx in the layouts directory. In layouts/Grid.tsx, add the following: import React from "react"; import ProjectCard from "../components/ProjectCard"; import type { Project } from "../layouts/Project"; const Grid = ({ projects }: { projects: Project[] }) => ( <div className="flex flex-col gap-12 max-w-screen-md mx-auto"> <h2 className="text-black font-bold text-lg">Projects</h2> <div className="grid grid-cols-2 md:grid-cols-4 gap-8"> {projects.map(({ meta }) => ( <ProjectCard key={meta.slug} title={meta.title} description={meta.description} slug={meta.slug} photo={meta.photo} /> ))} </div> </div> ); export default Grid; Let's add a demo project now! In the _projects at your root, create a new file. The name doesn't matter, but I find it helpful to make it the same as the URL slug. In that file, add soemthing similar to the following: --- title: Example Project slug: example-project date: February 1, 2023 description: Just an example project! photo: /projects/example-project.jpg --- Something about Example Project... The area between the ---'s is called "front-matter", and it's what we brought gray-matter in to parse for us. Everything under the second seperator is the content of the case study. It can be any valid markdown. Save the file and let's move on to tying things together! In your pages/index.tsx file that Next.js generated, replace the content with the following. Feel free to ignore the missing Project type for now. We'll add that soon! import React from "react"; import path from "path"; import { promises as fs } from "fs"; import matter from "gray-matter"; import dayjs from "dayjs"; import Grid from "../layouts/Grid"; import type { Project } from "../layouts/Project"; const PROJECTS_DIR = "_projects"; export async function getStaticProps() { const projectsDir = path.join(process.cwd(), PROJECTS_DIR); const files = await fs.readdir(projectsDir); const postPaths = files.filter((file) => { const ext = path.extname(file); return ext === ".md"; }); const projects = await Promise.all( postPaths.map(async (file: string) => { const contents = await fs.readFile(path.join(projectsDir, file), "utf8"); const parsed = matter(contents); const project = { content: parsed.content, meta: parsed.data, }; return project; }) ); const sortedProjects = projects.sort((a, b) => dayjs(a.meta.date).isAfter(dayjs(b.meta.date)) ? -1 : 1 ); return { props: { projects: sortedProjects, }, }; } export default function Home({ projects }: { projects: Project[] }) { return ( <main className="mx-10 sm:mx-0 my-10"> <Grid projects={projects} /> </main> ); } This may look a little confusing if you've never seen getStaticProps before, but you can read more about it in the Next.js docs. All it's doing here is getting an array of Projects that we then use dayjs to sort from a human-readable date in the metadata and pass that array as a prop to our Grid. Now just run pnpm dev and you should see your project on the home page! Feel free to add more projects and see how it sorts by date and adds them to the grid. Now, if you try and click on a project, you'll notice it takes you to a 404 page. Let's go ahead and add a single project layout by creating a file called Project.tsx in layouts. In Project.tsx, add the following: import React from "react"; import Image from "next/image"; export type ProjectMeta = { title: string; description: string; slug: string; date: string; photo: string; }; export type Project = { meta: ProjectMeta; content: string; }; const Project = ({ project, renderedProjectContent, }: { project: Project; renderedProjectContent: string; }) => ( <div className="flex flex-col gap-8 max-w-screen-md mx-auto"> <h2 className="text-black font-bold text-lg">{project.meta.title}</h2> <h3 className="text-gray-500 text-base">{project.meta.description}</h3> <Image src={project.meta.photo} alt={project.meta.title} width="0" height="0" sizes="100vw" className="w-full h-full" priority /> <div className="flex flex-col gap-8" dangerouslySetInnerHTML={{ __html: renderedProjectContent }} /> </div> ); export default Project; ⚠️ Note the dangerouslySetInnerHTML. It's ok to use here as the only user's content that will be rendered there is your's. This is not a great practice, however, and you should not do this on a platform that other's are able to post content to. In the next step, we are going to be writing a utility to generate that HTML. To do so, create a new file called markdown.tsx in the utils directory and add the following: import React from "react"; import Link from "next/link"; import * as ReactDOMServer from "react-dom/server"; import ReactMarkdown from "react-markdown"; const Img = ({ ...props }: any) => ( <img className="rounded-[8px] max-w-screen-lg mx-auto w-full" {...props} /> ); const Text = ({ children, node }: { children: React.ReactNode; node: any }) => { if (node.children[0].tagName === "img") { const image: any = node.children[0]; return <Img src={image.properties.src} />; } return ( <p className="flex-1 flex-grow w-full text-xs leading-6 max-w-screen-md mx-auto"> {children} </p> ); }; const Anchor = ({ children, href }: any) => ( <Link href={href} className="inline underline"> {children} </Link> ); export const renderMarkdownToHTML = (markup: string) => { return ReactDOMServer.renderToStaticMarkup( <ReactMarkdown components={{ p: Text, img: Img, a: Anchor, }} > {markup.trim()!} </ReactMarkdown> ); }; This component uses react-markdown to enable us to define JSX component mappings for markdown elements! Feel free to get creative here and expand upon what's already in there. So far so good! We're getting close. Now we just need to create a new file called [slug].tsx in pages/project that will pull that new component in. In [slug.tsx], add the following: import React from "react"; import { promises as fs } from "fs"; import path from "path"; import matter from "gray-matter"; import ProjectLayout from "../../layouts/Project"; import { renderMarkdownToHTML } from "../../utils/markdown"; import type { Project } from "../../layouts/Project"; const PROJECTS_DIR = "_projects"; export async function getStaticProps({ params }: { params: { slug: string } }) { const projectsDir = path.join(process.cwd(), PROJECTS_DIR); const files = await fs.readdir(projectsDir); const projectPaths = files.filter((file) => { const ext = path.extname(file); return ext === ".md"; }); const projects = await Promise.all( projectPaths.map(async (file: string) => { const contents = await fs.readFile(path.join(projectsDir, file), "utf8"); const parsed = matter(contents); return { content: parsed.content, meta: parsed.data, }; }) ); const project = projects.find((p) => p?.meta?.slug === params.slug); const renderedProjectContent = renderMarkdownToHTML(project?.content!); return { props: { project, renderedProjectContent, }, }; } export async function getStaticPaths() { const projectsDir = path.join(process.cwd(), PROJECTS_DIR); const files = await fs.readdir(projectsDir); const projectPaths = files.filter((file) => { const ext = path.extname(file); return ext === ".md"; }); const projects = await Promise.all( projectPaths.map(async (file: string) => { const contents = await fs.readFile(path.join(projectsDir, file), "utf8"); const parsed = matter(contents); return { content: parsed.content, meta: parsed.data, }; }) ); const paths = projects.map((project) => ({ params: { slug: project?.meta?.slug }, })); return { paths, fallback: false, }; } const Project = ({ project, renderedProjectContent, }: { project: Project; renderedProjectContent: string; }) => ( <main className="mx-10 sm:mx-0 my-10"> <ProjectLayout project={project} renderedProjectContent={renderedProjectContent} /> </main> ); export default Project; Similar to the index.tsx page, we are grabbing the list of projects again, however this time we are filtering to find the single project whose slug matches that in the dynamic page's URL. Then, we are defining a function called getStaticPaths that is another special Next.js feature to dynamically create the static paths for generated content (in this case, our Projects!). You can read more about that in the Next.js docs. Great! If you go back to the home page and click a project, you should now see it takes you to a single project page! That's really all you need to get started! You now have a simple markdown-powered portfolio. You can use this as a jumping-off point to continue building out the (easy-to-update) portfolio of your dreams! Here's some ideas to get you started: Use next-sitemap to generate a sitemap to all of your dynamic routes to make it easier for search engines to index your site As you probably noted, some of the code we wrote isn't very DRY; maybe extract some of the repeated code into more utils Add a navbar and footer Add more pages to tell visitors about yourself or how to get in touch with you Looking foward to see what y'all create! View the source on GitHub.
More in technology
On Sunday morning, a lot of us woke up to Grok Bot, the always-on AI assistant, decidedly off. “100% used” read my quota meter. It’d been 45% late the night before. It was an error, but of the Freudian slip sort, and that’s a problem if tools like this are to be taken seriously.
A Pika author was editing the same post in two browsers. They added six images in Browser A, then later they referenced an old tab editing the post in Browser B. That copy of the editor did not include those images. Browser B saved and removed all six images from Active Storage. A short time later, Browser A saved its newer copy of the post and put the image references back, but at that point the originally uploaded files were gone due to Browser B saving the post without the images. This exposed two separate problems: Missing images produced uncached 404 responses that the editor requested repeatedly. Action Text purged an image as soon as one save removed it from the body. Here’s how we fixed those problems. Why Action Text deleted the images Action Text keeps a rich text record’s embeds synchronized with the attachments in its body. When a submitted body no longer contains an image, Action Text removes its ActiveStorage::Attachment. Active Storage then purges the dependent blob, deleting both its database record and its file. That behavior makes sense when there is only one current copy of a document. It is unsafe when the same post can be open in multiple tabs, browsers, or devices. One stale save can delete an image that another editor is still using. Why the missing images flooded the server Browser A still had the six images in its editor, each pointing at an Active Storage redirect URL: /rails/active_storage/blobs/redirect/:signed_id/:filename Pika uses the ProseMirror-based Rhino editor. Rhino rebuilt an image’s DOM node when its caption or selection changed. Each new <img> element requested its URL again. Healthy images did not cause trouble since Active Storage makes its redirect cacheable. For a purged blob, Rails returned a 404 without Cache-Control, so the browser kept sending the request to Rails instead of reusing the failure. In a gallery, typing could re-request several missing images per keystroke. In these scenarios, this issue led to hundreds of requests hitting our servers. Fix 1: Cache permanent Active Storage 404s A purged blob’s redirect URL can never start working again. Blob IDs are not reused, and uploading the image again creates a new blob with a new signed URL. Sinc there is no concern of a 404 becoming stale, we made a specific 404 cacheable for the same period as a successful redirect: # app/controllers/concerns/active_storage_missing_blob_cache.rb module ActiveStorageMissingBlobCache extend ActiveSupport::Concern included do rescue_from ActiveRecord::RecordNotFound, with: :render_cacheable_not_found end private def render_cacheable_not_found expires_in ActiveStorage.service_urls_expire_in render file: Rails.public_path.join("404.html"), layout: false, status: :not_found end end We included the concern only in the controller serving these blob redirects: # config/initializers/active_storage_missing_blob_cache.rb Rails.application.config.to_prepare do ActiveStorage::Blobs::RedirectController.include(ActiveStorageMissingBlobCache) end (We also offered an update to Rhino that avoids it being so aggressive re-requesting images.) Fix 2: Detach dropped images instead of purging them When a save removes an image from an Action Text body, we now remove the attachment while temporarily keeping its blob. Another browser can save the image again and reattach that same blob. If nothing brings it back, a cleanup job purges it later. First, we added a timestamp for when the image was dropped: class AddDetachedAtToActiveStorageBlobs < ActiveRecord::Migration[8.1] def change add_column :active_storage_blobs, :detached_at, :datetime end end We then intercepted the private callback Active Storage uses to purge a dependent blob: # config/initializers/detach_dropped_embeds.rb module DetachDroppedEmbeds private def purge_dependent_blob if dropped_from_rich_text? blob&.update_columns(detached_at: Time.now) else super end end def dropped_from_rich_text? record.is_a?(ActionText::RichText) && !destroyed_by_association end end ActiveSupport.on_load(:active_storage_attachment) do prepend DetachDroppedEmbeds end The destroyed_by_association check preserves intentional deletion. Removing an image from a saved body detaches it, but deleting the post and its rich text still purges its images normally. Purge detached blobs after a grace period The cleanup query considers both when the blob was created and when it was detached: ActiveSupport.on_load(:active_storage_blob) do scope :unattached_since, ->(time) { unattached.where( created_at: ..time, detached_at: [nil, ..time] ) } end That scope handles both kinds of unattached blobs: A new upload that has never been attached becomes eligible based on created_at. An image removed from an old post becomes eligible based on detached_at. Pika gives both a 30-day grace period: class PurgeUnattachedUploadsJob < ApplicationJob GRACE_PERIOD = 30.days def perform ActiveStorage::Blob.unattached_since(GRACE_PERIOD.ago).find_each do |blob| blob.purge_later end end end If another browser saves the image during that period, it is attached again and the unattached scope excludes it from cleanup. The result Replaying the original two-browser edit now works safely: Browser B saves a body without the six images. The attachments are removed, but their blobs are only marked as detached. Browser A saves its newer body and reattaches the existing blobs. The images remain available and do not need to be uploaded again. If no editor brings a detached image back, the cleanup job eventually purges it. Much better! Reply by email
Yesterday, a group of MIT researchers released an article titled “Sparks of Weight-Space Programming: LLMs can natively read & write neural network weights”. The piece describes a set of experiments which convincingly show OpenAI’s latest Astra model is able to understand and successfully alter raw weight data. Wowzers! It’s a rough analogy, but an LLM’s weights are sort of like its brain. A set of weights is a large pile of numbers generated during training, and an LLM’s output is derived by running input through the weights using some matrix multiplication and a bit of randomization. While the steps to generate weights are well understood, the weights themselves are extremely opaque. There’s ongoing work to try and explain how weights work, but researchers currently have a limited understanding of what these numbers do. Based on this, I was quite surprised to learn that LLMs are able to act on raw weight data — even if it’s still just in limited cases. It’s unclear if OpenAI’s model was trained on weight data. Even if it was, I still find these results surprising. The article shows the model accurately predicting what a never-before-seen set of weights was built to do, fixing another model by updating its weights, and creating new sets of weights that performed simple tasks. I would not have guessed any of that would be possible a couple of days ago. This whole thing is slightly unnerving. The whole “weights as a black box” thing has always felt a bit sketchy to me, even when I was working with much simpler machine learning models. Now these experiments hint at a future where LLMs could be self-improving in ways that aren’t human-verifiable. Machines building machines, and we can only hope that they’re not doing anything weird. I don’t know if this strategy is scalable, but I’d guess that it’s something more people will be exploring very soon. More and more often these days, I wish we were living in less interesting times.
Harry threw me off course in episode 48 of Traction Heroes. At first, I thought was reading from a book about mind mapping. Soon, it became clear it was about something else: how to define a clear vision for a team or organization. Visions are notoriously hard to articulate. Drawing a picture — especially a picture of the whole forest, plus the trees — can help. The passage Harry read from Cameron Herold’s Vivid Vision explores this idea: So to visualize something a little more tangible, think about a tree trunk in the earth, inner core with its branches stretching out into the sky. Each of those branches contains twigs extend further out. The mind map works in a similar way, a core that branches out into smaller components. Now, apply this to the world of business. As the leader of an organization, you’re firmly planted at the center, and the various areas that contribute to the business branch out: marketing, sales, engineering, and so forth. With that in place, build off that by describing those branches in about three or four bullet points each. In other words, describe what your marketing looks like and sales and so on until you’ve captured every idea. Remember too that among the branches of your business, you want to consider every aspect of the organization: the vibe, informal codes of conduct, the fiscal environment, the scenery, all of that. Deliberately leave out numbers and data. Get a feel for the place. When you step out of your time machine three years from now and walk around the office, what’s happening? What can you say that is typical of the business three years from now? The mind map is an initial foray into the vivid vision and will act as an outline for the rough draft that you, the leader, will then write. I’ve sketched diagrams before when outlining articles, workshops, and presentations, never in service of aligning around a company’s vision. Harry’s done it several times and found it easier than trying to write it out. Seems useful — check out our conversation for more: Traction Heroes episode 48: Visions
Sharing a few data points because some people did not expect these drives to last that long.