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
Here are the Vim forks I know about: Neovim: “a Vim-based text editor engineered for extensibility and usability, to encourage new applications and contributions.” Vim Classic: “a stable, long-term support fork of the ubiquitous text editor, maintained without the assistance of generative AI tools.” EVi: “a hard-fork of Vim v9.1.0 (Jan 2024) before AI was used in the project.” MacVim: “a macOS version of the Vim text editor, providing a graphical user interface for Vim, while tightly integrating with macOS and providing features specific to the platform.” This list doesn’t include Vim-inspired editors like Kakoune, Vim-style plugins for other editors like IdeaVim, or Vim “distributions” like LazyVim. Contact me if you know of other forks!
Thought I’d make a quick blog post about some weird behavior I’m seeing on iOS 27 and Dynamic Island live activities, that I initially thought was restricted to the Duo, but turns out affects everything. Yay. Maybe this post can save some others some time, and hopefully Apple can fix it. Long story short, ever since iOS 16.1 introduced “Live Activities”, where we can display handy little widgets in the Dynamic Island, there’s been a bunch of different configurations for showing things up there: something on the left, the right, when expanded, when two apps are showing things and yours gets a bit smaller, and on the lock screen. One interesting one is if you only need to show a small indicator in the Dynamic Island (screen recording indicator, electric vehicle charging status, etc.) you can do so, which just shows on the left on older iPhones, and on the Duo a single one below the camera cutout. Apple describes how to do this in their developer documentation (emphasis added): If you can’t find a solution to display views for leading and trailing compact presentations with limited width, provide a compact leading presentation that best represents your Live Activity, such as the view you use for the minimal presentation, and an empty view for the trailing compact presentation. The system hides the empty trailing view automatically. Unfortunately, in iOS 27 (and 27.1, and 27.2), this only half works, due to a new addition in iOS 27: isDynamicIslandLimitedInWidth. It’s a variable that basically tells you if the Dynamic Island is even narrower than normal, presumably because they knew the Duo was coming with its vertical Dynamic Island, but also because as of iOS 27, Live Activities now show in the Dynamic Island when the device is in landscape too. So it’s handy to know if you have a bit less space than usual. In this new narrow view however, it seems like they didn’t test all the configurations thoroughly, as if you provide EmptyView for that trailing compact presentation, it’s not empty at all, and in fact enforces a quite large minimum size so the Dynamic Island looks hilariously tall. In other words: The system hides the empty trailing view automatically Left: what it should look like. Right: reality You can imagine this is especially rough on the Duo, where navigation buttons, toolbar buttons, tab bar buttons, etc. are all competing for precious space on the right. But that’s only half true. It displays perfectly fine (hiding it) when isDynamicIslandLimitedInWidth is false (AKA how it always used to be), but in the new cases where it can be true, like the Duo and iPhones in landscape, that’s when it breaks. So tl;dr: In iOS 27, Dynamic Island Live Activities are improperly sized if you use the listed EmptyView configuration and isDynamicIslandLimitedInWidth is true. This makes it especially bad on the Duo, womp womp. Complications Now, you might be saying “That’s weird, I haven’t seen that behavior! This is nothing but hogwash!”. But it is not hogwash, just a little complicated. There’s basically three categories of apps. Apple apps Maps, for instance, displays this single, small Dynamic Island live activity perfectly well and sized, and has been beautifully compiled with the iOS 27.2 SDK. By all accounts it should show this bug, too! Apple apps don’t have to use the same APIs as us normies, so funnily enough through really cool tools like REA we can disassemble the iOS Simulator and see that it’s in fact true, the Apple Maps live activity just completely bypasses WidgetKit and instead just renders plain UIKit through MapsBannerViewController which shows the cute little cyan widget through a ManeuverAccessoryView and then for the hidden trailing view simply returns a DistanceAccessoryView with CGSizeZero, which iOS actually respects, unlike our paltry SwiftUI EmptyView. Third-party apps NOT updated to iOS 27 If your app hasn’t been compiled with any of the iOS 27 SDKs, it only knows about iOS 26, and thus doesn’t know about (or have to worry about) the new, troublesome isDynamicIslandLimitedInWidth property, so it just… displays perfectly well. Here’s the Rivian app, which hasn’t been updated for iOS 27 yet, which shows the Live Activity displaying perfectly sized on the Duo (indicating vehicle charging): (Technical disassembly detail for the nerds: iOS shows the app’s minimal config, a smaller config normally used when multiple apps are in the Dynamic Island together, rather than the normal compact config, so it bypasses this issue entirely.) Developers who updated their apps for iOS 27 Surprise! That picture from before? On the left is just me compiling with the old 26 SDK that works! Unfortunately if you are a good developer who updated their app for iOS 27 and all the new features, you kind of get punished through this bug, because your live activity displays the improperly sized way described above, and you can’t just use raw UIKit like Apple. So you’re kinda stuck where I am: do I ship a weirdly sized Dynamic Island experience for everyone? Do I shove something extraneous in the compactTrailing spot just so it doesn’t look as empty? Do I not update the app at all until Apple fixes this bug? Widgets are techincally a separate extension from your main app, so you could compile them with a different SDK (26) than your main app (27) which fixes this bug, but I’m not sure Apple approves of that. Apple On the bright side hopefully it’s an easy enough fix, treating EmptyView like you used to on iOS 26 and per the documentation (the part that is weirdly right beside the mention of the new API that introduces this craziness in the first place). Radar/feedback assistant ID: FB25301955 Easy reproduction code if you’re curious: import ActivityKit import SwiftUI import WidgetKit struct CircleAttributes: ActivityAttributes { struct ContentState: Codable, Hashable {} } struct BlueCircle: View { var body: some View { Circle() .fill(.blue) .frame(width: 20, height: 20) } } @main struct CircleLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: CircleAttributes.self) { _ in BlueCircle() } dynamicIsland: { _ in DynamicIsland { DynamicIslandExpandedRegion(.center) { BlueCircle() } } compactLeading: { BlueCircle() } compactTrailing: { EmptyView() // pls respect me } minimal: { BlueCircle() } } } }
Vicky, driving me home from O'Hare, got sixteen dollars for the best conversation I had in months. The last line is hers.
I’ve had “AI usage statements” in the back of my mind ever since I read Ethan Marcott’s blog post sharing a round-up of his favorite ones. I didn’t consider writing one for this site, since it had the smell of status-signaling. Like a quasi-political declaration of allegiance to some sort of in-group (be it pro-AI, anti-AI, etc). I couldn’t be less interested in that.1 But then something happened that caught me completely off-guard. I was reading a post on my friend’s personal blog. This was a guy that I knew personally and looked up to. I’ve been reading everything he had been writing for years. About two-thirds of the way through a long (and fairly insightful) blog post, I had a sudden realization that the post had been written by AI. I’m not the best at detecting this kind of thing, but once I saw it, I couldn’t unsee it. It was unmistakable. And the thing is, I didn’t feel indifferent or even disappointed, as I might have predicted. I felt betrayed. For somebody who considers themselves open-minded—even optimistic—towards the development of AI, I didn’t know where this feeling was coming from. I have theories… maybe it has something to do with the social contract between the writer and the reader. Whatever it is, I didn’t want anyone who was reading my blog (hi mom 👋) to feel the same thing. So for the sake of transparency, here is my AI usage statement: The content of this website is written by me. I do not use AI to generate the text you are reading because writing from scratch helps me process my thoughts (which is one of the main reasons I blog in the first place). I don’t currently use AI-powered writing tools (grammar-check, spell-check, dictation tools, etc), but I may experiment with them as long as they don’t undermine the thinking process. The code for this website was originally hand-written, but I have since used AI to make several useful updates. I expect to continue using AI to write code for this website and other personal coding projects. When I create a public project exclusively with AI, I mention that fact in the project README.2 I am experimenting and my AI usage may change. When it does, I’ll update the policy here on my /about page, which is where it will live long-term. To explain why, see Paul Graham's Keep your Identity Small. ↩ For an example, see s-tier ↩ Thanks for reading this post via RSS! Let me know your thoughts by leaving a comment on the original post or sending me an email.
Amend 4.0 has been released: amend-4.0.sit (StuffIt 3 archive, includes source code and THINK C 5 project file) c838ee34931c00c03869affe7f9d6b85c0bbc51dd1242d11e8682a3774fdd0e3 SHA1: 040059d0b3302f1bd6ec0207b9a2f12dccdda374 Changes in this version: New repository format that tracks author e-mail in addition to name; existing repositories are migrated automatically on open Add inregrated resource file support; .rsrc files can be added directly to a repo and files are converted to DeRez-style ASCII output before diffing, so running SADerez to produce separate .r files on every change is no longer required Allow Command+. to cancel loading a repo Allow Command+. to cancel diffing; if diff text has already been output it will remain available, otherwise the window will be closed Center settings dialog, make sure author name is present Use database transactions to avoid leaving repository database files in a partially-committed state if we crash Numerous bug fixes