Introduction to the kitchen

Itā€™s been a long time since I build any personal project and Iā€™ve decided to build a personal site so the internet would notice me to share some of my findings to help myself and others learn interesting stuff. As Richard Feynmann said:

If you cannot explain something in simple terms, you donā€™t understand it

P.S. Please donā€™t look at it as a much of educative article, more like personal opinion/experience. If you want to learn Astro, open the docs and follow the tutorial!

Iā€™ve heard about Astro a lot before and even used it in one of my work projects, but I have never dived deep enough to figure out why people even need static sites and why not just build everything in React.

There are frameworks which provide Static Site Generation (SSG) and Server Side Rendering (SSR) like Next.js, Nuxt, Remix, SvelteKit and the good-ol Gatsby, but I felt Astro is a frontier in pushing SSG to more like a web standard and wanted to learn about it more. Apart from the fact that it provides very comforting Developer Experience with native Markdown and MDX parsing and many integrations with plug and use approach for all things content, it also builds upon the new to web-development approach: Islands Architecture

Old cheeseā€™s history

People were thinking about interactivity from the start. It made websites alive! And JavaScript provided that, even while being a tricky language. But as everybody probably thinks now, JavaScript could actually slow down userā€™s experience. It is especially aparent on mobile devices as parsing and executing code even highly optimized by bundlers is consuming battery charge and taking the small network resources they have. Unlike images and static HTML which could be cached, JavaScript must be re-used and re-executed each time it loads

And as many would cite, if the page is slow, any website owner would suffer from losing its audience as it feels that the page is cheap and maybe even a scam. Plus to that, when using just Client-Side rendering like Vue or React provides you with, your website may not be as SEO-friendly and crawlable by Googlebot (although it betters over time).

Another drawback is, when loading a webpage, your users may see a sudden flash, maybe even prolonged one while any scripts load at all, which could ruin every experience.

Here I want you to understand a difference between two terms: Website and Web applications.

Client-side frameworks have received their growth because it became trendy to build Web applications that are no less than real computer apps that you download an installer for, while being much more easy to work with. They were efficient at that: many approaches at reactivity made sure every new tool got better (for example modern approaching switch from VDOM to compilers)

Even more, there are apps that ship to desktop while running Frontend WebView under the hood, like Electron and Tauri. They are in the demand, and VSCode, Discord, Spotify, even FeedTheBeast Minecraft launcher are running Electron because it is easy to build after having experience with using these tools on frontend. Those have a very high degree of interactivity.

This is a Web Applications (By freepik):

Web app

And on the other side of the coin is Websites. Those are news magazines, personal portfolio pages, blog sites, even e-commerce, documentation pages and so on. There are no complex logic behind them. They may use Wordpress or some other CMS, and even build with native JavaScript, as most of the content is really just HTML & CSS. At best, they may a custom mobile burger and some carousel with one Call to Action modal or a form that they donā€™t need huge Vue package for.

This is a Website (By freepik):

Website

Websites may not even require build step. There is not much dynamic info on them, so they could be easily cached.

Web applications may update data each millisecond so you would keep track of your ad revenue. They may show you tens of dozens complex charts that you could look at, while running in your browser.

And so, to use React, Vue and other rendering libraries for the purpose of building websites, people have introduced Meta frameworks like Next.js for React and Nuxt for Vue. Those meta frameworks are responsible for making experience with the technology applicable to build a landing page for your web app with providing developer to Server-Side render their app, and then hydrate every interactive part to the client. That means, instead of sending empty HTML pages with modules to bootstrap the application at userā€™s device expense, there would be a server-entry.js file generated with express or Nitro under the hood to serve requests from Node or other runtime.

It makes app faster to serve: your database and file system is much closer to the server, which reduces latency rather than fetching it from outside. Plus, server-side environment provides an ability to have secrets in it without them being easily leaked like it happens on the client. So, the app becomes more coupled and universal.

Having mastered Vue, you could use that to build a e-commerce store that would mostly look like a bunch of HTML & CSS sprinkled with JavaScript, but with the speed of building with components, reactivity features and ecosystem, even when Vue is not much concerned with what all that is.

With Meta frameworks, all problems related with Time-To-Interactive metric, Seo, and flashes would seemingly be gone. But even though SSR can be cached and managed properly, having to adapt this mental model could be quite complex. Trying to dissect where components run and should run is a mental excersise. You can feel it most crucially in ā€œthe new Next.jsā€ with React Server Components adaptation.

Where do I put my code, on the server, or on the client?

Where do I get the data from db?

Is it safe here?

And it is easy to make mistakes, just how it may be easy to ruin your memory handling in C or run into undefined behaviour, while mistake could go unnoticed until it ruins someone elseā€™s (or even yours) experience.

Molecular dishes at the rescue

Astro is different. There is a clear separation between what runs where. It is achieved through that paradigm:

Everything is either prerendered or ran on the server, and you may add JavaScript precisely where you want it.

So, everything is at your control. You may not give into a behaviour you donā€™t want, and Astro ensures this.

An example of this would be this tree of components:

index.astro
1
<ComponentA>
2
<ComponentB>
3
<ComponentC>
4
<ComponentD client:load />
5
</ComponentC>
6
</ComponentB>
7
</ComponentA>

Here, ComponentD is hydrated to the client, means that the client-side logic inside would be working, but everything else is not. So, ComponentC would not have a clickable button or a working JS slider, and no JavaScript, secret API keys would be leaked to the other side.

Also, all Astro components are purely static and run on the server, so you have a capacity to import a database client, running your environment (like Node or Cloudflare Workers) API, using operating system and so on.

So, unless you specify explicitly to send component to the client, it would not be interactive. In SSR, that is reversed, which may not be a good way to handle building Websites. And hereā€™s where Astro shines.

Grab that!

Simple counter that works!

0

Sorry for that bunch of Vue bundle I got into your browser right now šŸ‘€ However, you didnā€™t actually download it before you have seen it, because I added it like so, with a directive: <Counter client:visible />. It works with IntersectionObserver that tracks the elementā€™s visibility on the screen, and if it is visible, it will hydrate it to the client.

That makes Astro a meta-framework for every UI-library there is: Solid, Svelte, Preact are no exceptions. It is simple and efficient, easily landing you all 100ā€™s on Lighthouse score.

Astro prevents mistakes of leaking JavaScript, helps with in-built optimizations, provides an ecosystem of plugins and integrations for every task youā€™d have at building and very thorough thourough documentation and warming community at Discord that helps.

So, hereā€™s my path!

Mistook apples for oranges

I thought the fastest way to learn and actually use was to copy someone elseā€™s template and build upon it. Astro Erudite as I was searching through github as it had everything that I wanted and more. So, I frivoulously cloned it and right away decided to switch UI kit to Daisy as I see shadcn-ui as a more SaaS-y approach to styling. That was my horrible mistake and a great blessing, since I spent an immeasurable amount of time trying to decode someone elseā€™s code and decisions while swapping components one by one. At last, I ended with a half of enscribeā€™s child and my own homunculus:

Here's the result

I wanted to learn more about Astro after finishing rebuilding this template, but at the end I just snapped and started my own project. I took a lot of inspiration and learning in parts which I didnā€™t understood well from it, so thank you the great Open-Sourcer!

So, hereā€™s my take on Astro. You can get all the source snippets from the repo. Note that sometimes Iā€™m citing partial snippets to show the main ideas, but the full code is always available there. Also, I use Astro v4.15.11, so check your version to avoid inconcistencies and misinformation about newest practices.

To build a static website, you will come at some issues right away and some will come later. Hereā€™s my full list (aside from the Astro itself, that the documentation help you will for):

  • How to style it? šŸ–Œļø
  • Where to get icons? šŸŽŽ
  • How to write in Markdown and stay interactive? šŸ–Šļø
  • How to highlight code? šŸŒˆ
  • How to add client-side logic? šŸ­
  • How to make it searchable? šŸ”
  • How to store persistent data? šŸ’¾
  • How to deploy it? šŸš€
  • How to make it cool? šŸ˜Ž

That would be the contents of this article. Letā€™s begin!

Instagrammy bacon and eggs

As a styling solution I decided to stick with Tailwind as it is the fastest way to write CSS. Some of it may look ugly, and it did, but whatā€™s happened happened.

To get started, I had to enter just one command:

Terminal window
1
pnpm astro add tailwind

Here, Astro CLI installed integration for me and created tailwind.config.ts for me. You could use other name, for example solid to install solid integration. Or you may have added @astrojs/tailwindcss package yourself and put it into integrations:

astro.config.ts
1
import tailwind from "@astrojs/tailwind";
2
3
// in config
4
integrations: [ tailwind() ],

For the UI-library, as I mentioned, I took daisyUI which got me tinker with it for a bit before actually getting the way to use it, but in the end it was much worth it as I didnā€™t have to write many usable things from scratch and got an immensely good home-looking design for free, which would take ages to build from scratch.

Multiple icecream dressings

One of the most enjoyable things about Daisy is its inbuilt themes support. Take a look at the theme picker right here:

And Daisy has even more themes, although I would hesitate to add them all because the bundle size would grow a little.

This dropdown is built with the use of cool interaction based on MutationObserver, which powers I lent for the first time. While my component renders a dropdown that changes a document data-theme property:

Layout.astro
1
---
2
3
<div
4
class="dropdown"
5
>
6
<div
7
tabindex="0"
8
role="button"
9
>
10
Button
11
</div>
12
<ul>
13
{
14
// render all themes from config
15
THEMES.map((theme) => {
16
return (
17
// add data-theme to showcase new colors in a button
18
<li data-theme={theme}>
19
<div
20
// give ids to add event listeners later
21
id={`theme-switcher-${theme}`}
22
>
23
{theme}
24
</div>
25
</li>
26
);
27
})
28
}
29
</ul>
30
</div>
31
32
<script>
33
const handleSwitchTheme = (theme: string) => {
34
document.documentElement.dataset.theme = theme;
35
};
36
37
// add event listeners to switch document theme
38
document.querySelectorAll(`[id^="theme-switcher-"]`).forEach((el) => {
39
el.addEventListener("click", (event) => {
40
event.preventDefault();
41
const theme = (event.target as HTMLElement)?.parentElement?.dataset
42
.theme as string;
43
handleSwitchTheme(theme);
44
});
45
});
46
</script>

Script loaded in the document watches the changes and reacts with localStorage syncing, while loading the theme from it at first too:

1
<script is:inline>
2
const darkThemes = ["dark", "aqua"];
3
const THEME_KEY = "theme";
4
const getThemePreference = () => {
5
// if saved return that
6
if (typeof localStorage !== "undefined") {
7
const theme = localStorage.getItem(THEME_KEY);
8
9
if (theme) {
10
return theme;
11
}
12
}
13
14
// grab user's preferred color scheme if not
15
return window.matchMedia("(prefers-color-scheme: dark)").matches
16
? // must be synced on its own without config because I don't wanna
17
// import themes into head on page load
18
"night"
19
: "autumn";
20
};
21
22
const getDocumentTheme = () => {
23
return document.documentElement.dataset.theme;
24
};
25
26
const setDarkMode = (document) => {
27
const theme = getThemePreference();
28
29
document.documentElement.dataset.theme = theme;
30
};
31
32
setDarkMode(document);
33
34
if (typeof localStorage !== "undefined") {
35
// creating observer instance.
36
// when `data-theme` defined below changes, it syncs local storage
37
const observer = new MutationObserver(() => {
38
const theme = getDocumentTheme();
39
if (!theme) return;
40
localStorage.setItem(THEME_KEY, theme);
41
});
42
43
observer.observe(document.documentElement, {
44
attributes: true,
45
attributeFilter: ["data-theme"],
46
});
47
}
48
</script>

This is way too cool! šŸ˜Ž

One important thing about this approach is that I had to make script inline and put it at the start of <body>, so the website would not FART flicker when loading initially as it would load the script ahead before rendering the page.

Inline means that Astro would not optimize my script and bundle it as a module, but leave it as-is. It removes the ability to use TypeScript and import utilities from other parts of the project, but the importance here is to provide small as possible way to load the theme without any flickers.

Iā€™ve got into a rut with this issue because I didnā€™t really know how page loading was done deep down, so I was putting the script below body, above body and finally at the end of the head to figure out what did not mess my page load and would not flicker. This highlights how important it is to get good with the basics.

Writing attractive invitations

As for typography, Daisy recommends using @tailwind/typography package, which I went with. It is nice on itself, and very tweakable - for example when rendering markdown I wanted to remove this pluginā€™s styles on <code> blocks, and here its done with just one line:

1
<div class="prose prose-code:not-prose">

Another thing I want to mention is cn function:

1
import { clsx, type ClassValue } from "clsx";
2
import { twMerge } from "tailwind-merge";
3
4
export function cn(...inputs: ClassValue[]) {
5
return twMerge(clsx(inputs));
6
}

I think you can add it literally in any project with Tailwind, since it is a live-saver as it does two things:

  • It merges all classes with the way resembling CSS specifity - last one apply.
  • And it removes unnecessary classes when duplicates appear. For example, with mr-2 and ml-2 appearing, it would collapse them to just mx-2.
  • It provides nice API to work with conditional classes instead of ugly &&ā€™s:
1
<div className={cn("class-1 class-2", {
2
"class-3 class-4": isSomeCondition,
3
})}>

Well, anyway, some may find it worse than &&ā€™s, but to each their own šŸˆ

Looking for jar stickers

Every website probably needs icons, and almost every time I start a new project I look for a way to get them. Some people make a map like that:

icons.tsx
1
export const ICONS = {
2
General: {
3
Github,
4
Twitter,
5
Telegram,
6
RSS,
7
} // and so on
8
}

Some people use feathers and add icons directly to their css.

Some people use Lucide or Iconify with inbuilt components for frameworks. I have gone with it too, since Astro has an astro-icon integration. However, it required me to specify what icons I wanted to have, otherwise it warned me that it would load them all in the bundle. I did so, and was quite unhappy:

config.ts
1
export enum IconSet {
2
Catpuccin = "catpuccin",
3
// other sets
4
}
5
6
export const ICONS = {
7
[IconSet.Catpuccin]: {
8
typescript: "typescript",
9
// other icons
10
},
11
} as const;

And in Astro config:

astro.config.ts
1
import { ICONS } from "config";
2
3
import icon from 'astro-icon'
4
5
export const config = {
6
integrations: [ icon({ icons: Object.values(ICONS) }) ], // map all used icons somehow
7
};
1
<AppIcon set={IconSet.Catpuccin} name="typescript" /> <!-- type inferred -->

With that, I couldnā€™t even use this component in my UI-libraries code too. So, it became a mess.

Then, I stumbled upon @iconify/tailwind package which makes tailwind work with iconify through classes. Hereā€™s how it looks:

1
plugins: [
2
daisyui,
3
typography,
4
addIconSelectors({
5
prefixes: ["line-md", "logos", "la", "game-icons"],
6
}),
7
scrollbar,
8
],

And in the code:

1
<span class="iconify la--address-book"></span>

To render:

I made a small wrapper component in Preact (as I decided it would be my rendering tool) for it, so I could tweak it later and find all my icons and also automatically add iconify class, it is needed to not add all needed icon styles to every icon class and reuse just one:

Icon.tsx
1
import { cn } from "@/lib/cn";
2
import type { FC, HTMLAttributes } from "preact/compat";
3
4
interface Props extends HTMLAttributes<HTMLSpanElement> {
5
className?: string;
6
}
7
8
export const Icon: FC<Props> = ({ className, ...rest }) => {
9
return <span {...rest} className={cn("iconify", className)} />;
10
};

If iā€™d wanted, I would just use a class in my other UI component if I could not import this one there. Problem solved.

Restaurantā€™s layout

Markdown is a very friendly way of writing articles. You could of course use a CMS with some inbuilt HTML editor, or use HTML directly to style it later, but it just adds unnecessary. With Markdown, you could write text that looks great even when looking at it unparsed, you could load it to Github and share with others in Telegram or have Obsidian store it.

For the purpose of building interactive blocks like the counter above, I have choosen to use @astrojs/mdx integration. MDX is a language that provides the ability to run JSX in Markdown files. But in Astro, that is not all: you could run any framework component without it necessarily being tied to React or Solid. MDX has also a feature of substituing components to render.

Markdown is a first-class citizen in Astro. There are ways to render it to the user: you could use .md or .mdx file extension in pages/ folder like so:

1
project
2
ā””ā”€ā”€ā”€pages
3
ā”‚ ā”‚ index.astro
4
ā”‚ ā”‚ some-page.md

Or as a bunch of files later fetchable with import.meta.glob:

1
const blogPosts = import.meta.glob("./blog/*.mdx"); // gets all files in blog directory ending with *.md
2
3
for (const post of blogPosts) {
4
console.log(post);
5
}

But recommended approach is to use Content Collections. It is a framework-specific feature with reserved content/ folder at src directory of the project. Hereā€™s my content/ while writing this article:

My content folder

There is one important file: config.ts that looks like so:

config.ts
1
import { defineCollection, z } from "astro:content";
2
3
const postsCollection = defineCollection({
4
type: "content",
5
schema: ({ image }) =>
6
z
7
.object({
8
title: z
9
.string()
10
.max(60, "Title should be 60 characters or less for optimal Open Graph display."),
11
description: z
12
.string()
13
.max(155, "Description should be 155 characters or less for optimal Open Graph display."),
14
date: z.coerce.date(),
15
image: image(), // here is an `image` zod object by Astro
16
// we could check whether image has required dimensions for example like so:
17
// image().refine(image => image.width === 1200 && image.height === 630, {
18
// message: 'The image must be exactly 1200px Ɨ 630px for Open Graph requirements.',
19
// })
20
imageAlt: z.string(),
21
tags: z.array(z.string()).optional(),
22
draft: z.boolean().optional(),
23
})
24
});
25
26
export const collections = {
27
posts: postsCollection,
28
};

Here, I give an example of using image() zod validator from Astro, but in the end I have decided to not have images in my blog. I think it is unnecessary for users to download some 500kb and look at blank screen outside the preview when reading technical stuff.

config.ts uses defineCollection helper alongsize re-exported zodā€™s z to build type-safe schemas for blog posts. Then, it is important to re-export collections constant for astro to notice it. When you start your project with astro dev or run astro sync directly, it would generate types for your collection to use in the project:

1
import { getCollection } from 'astro:content'
2
3
const posts = await getCollection('posts') // of type CollectionEntry<'posts'>

And hereā€™s my frontmatter - a metadata of the post:

1
---
2
slug: "astro-blog-from-scratch"
3
title: "Cooking static blog with Astro"
4
description: "There's many ways to build a static site. I've gone with Astro and want to share what DX I had and what paths I took."
5
date: 2024-06-10
6
image: "./Preview.jpg"
7
imageAlt: "A Vitruvian man with head as Astro logo and Penis as Vanilla JS script holding Tailwind, Daisy, Solid and Shiki, with legs as Lighthouse & Robots.txt logo"
8
tags: ["astro", "shiki", "markdown", "seo", "tailwind", "lighthouse", "mdx"]
9
draft: true
10
---

I used a slug property myself, but Astro generates it automatically from file name. I wanted more consistency and control here, since later I would like to save some data in db for each post.

Then, we could render posts as a list of links:

src/pages/index
1
---
2
import { getCollection } from 'astro:content'
3
4
const posts = await getCollection('posts')
5
---
6
7
<ul>
8
Links to articles:
9
{
10
posts.map(post => <li><a href={`/blog/${post.slug}`}>{post.data.title}</a></li>)
11
}
12
</ul>

And use dynamic routes to render each one:

src/pages/blog/[...slug].astro
1
---
2
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
3
import { getCollection } from "astro:content";
4
5
export const getStaticPaths = (async () => {
6
const posts = await getCollection('posts')
7
8
// we statically generate an array of each param:props key-value pairs
9
// that Astro will use to generate [slug].html files later
10
return posts.map((post, ix) => {
11
return {
12
params: { slug: post.slug },
13
props: { post },
14
};
15
});
16
}) satisfies GetStaticPaths;
17
18
interface Props extends InferGetStaticPropsType<typeof getStaticPaths> {}
19
20
const { post } = Astro.props; // type-inferred { post: CollectionEntry<"posts"> }
21
const { Content, headings, remarkPluginFrontmatter } = await post.render();
22
---
23
24
<Content />

From render method we receive <Content> component that we could directly use in Astro markup, list of all headings flattened out and remarkPluginFrontmatter that carries data we passes to it (for example, from readingTime plugin).

As this is all generated upfront, we could include complex logic to fetch data from CMS, run CPU-intensive tasks like sorting or optimizing, and this would not hurt our users experience.

Combining the kitchenā€™s efforts

As the browsers canā€™t render Markdown natively, there should be a way to convert it to HTML.

Astro uses Unified for parsing markdown under the hood.

Unified is a toolset for processing Markdown, MDX, human-text and other types of content into ASTā€™s (Abstract Syntax Trees) and vice-versa - transforming them back and forth between each other to give you the result you need. It gives developers a lot of flexibility to customize how the content goes from being an .mdx file to HTML being presented to the user.

There are two remarkable libraries in this ecosystem I needed: remark and rehype. Remark is a markdown processor, while rehype is an AST-to-HTML processor. For example, hereā€™s the remark plugin recipe from Astro docs, that adds reading time to articleā€™s metadata:

1
import type { Root } from "mdast";
2
import { toString } from "mdast-util-to-string";
3
import getReadingTime from "reading-time";
4
import { filter } from "unist-util-filter";
5
6
export function remarkReadingTime() {
7
return function (tree: Root, { data }: any) {
8
// filter out code blocks from reading time
9
const textOnPage = toString(filter(tree, (node) => node.type !== "code"));
10
const readingTime = getReadingTime(textOnPage);
11
// readingTime.text will give us minutes read as a friendly string,
12
// i.e. "3 min read"
13
data.astro.frontmatter.readingTime = readingTime.text;
14
};
15
}

It removes code blocks from a tree, then converts a tree to text to calculate the reading time from it.

As for rehype, I havenā€™t wrote a plugin myself as I didnā€™t need too: the ecosystem provides them at large. I used those four guys:

  • rehype-slug for making headingā€™s slugs to later target them in links via #heading
  • rehype-autolink-headings to add links to headings, so users would be able to click on them and share with others
  • rehype-sectionize to add section tags wrapping each heading to make it easier to target and style and for better SEO
  • rehype-external-links to add rel="nofollow" and target="_blank" to external links and style them accordingly

Also, thereā€™s rehype-pretty-code for handling and styling code blocks which I tried to use, but it doesnā€™t support multiple themes with MDX yet because of some unknown bug, so I had to put it down for something else: about this in the next section.

To parse MDX itself thereā€™s an integration @astrojs/mdx that works with all remark and rehype plugins. When used, it receives all options passed to markdown property in the config, so thereā€™s no need to copy them:

astro.config.ts
1
markdown: {
2
syntaxHighlight: false,
3
remarkPlugins: [remarkReadingTime],
4
rehypePlugins: [
5
[rehypeSlug, rehypeSlugOptions],
6
rehypeSectionize,
7
[rehypeExternalLinks, { target: "_blank" }],
8
[rehypeAutolink, { behavior: "wrap" }],
9
],
10
},
11
12
integrations: [
13
mdx(), // all plugins copied here automatically
14
],

Exquisite solyanka

main.rs
1
const explanation: &'static str = r#"
2
Solyanka (also spelled sel'yanka) is an old Russian national soup dish.
3
The word "solyanka" has a figurative meaning in Russian as
4
'mishmash', 'hodgepodge', 'disorder',
5
'diverse mixture of the most diverse, hetergenous elements'
6
"#

As a programmerā€™s education resource, I was of course obliged (unlike certain linkedin fella) to make working with code blocks as smooth as possible. That meant syntax highlighting, line numbers, diffing, copy button and so on.

For highlighting code there exist two libraries that I know of: Prism and Shiki. First of all, Shiki is a cool name, second of all, Prism seemed quite outdated and abandoned, and third of all, this Anthony Fuā€™s article convinced me that it was great.

Shiki, contrary to Prism, works not only at the client, but ahead of the time compiling at the server too, and it is actually used in VSCode as a highlighter, so great support and high amount of themes to choose is imminent.

You could write a plugin yourself, but it would be a lot of work of course. rehype-pretty-code plugin I mentioned above is a great one as it provides almost all the features I needed out of the box, but the problem I mentioned was a big turn-off for me as I wanted to do multiple (2 YEAH!) themes instead. At the time I was trying to make it work and stumbled upon expressive-code.

expressive-code is a library that is used under the hood in Astro Starlight documentation template. Out of the box it was way cooler than rehype-pretty-code and there was no issues at all. I even wrote my own plugin to blur some lines like that:

1
import {
2
secret,
3
anotherSecret
4
} from "@expressive-code/core";1
5
6
secret(anotherSecret)

since it didnā€™t have its own. Here:

plugins/expressive-code-blur.ts
1
import { type ExpressiveCodePlugin } from "@expressive-code/core";
2
import { addClassName } from "@expressive-code/core/hast";
3
4
export interface PluginBlurSettings {
5
blurredLines: Record<number, boolean> | undefined;
6
}
7
8
declare module "@expressive-code/core" {
9
export interface ExpressiveCodeBlockProps extends PluginBlurSettings {}
10
}
11
12
export function pluginBlurLines(): ExpressiveCodePlugin {
13
return {
14
name: "Blur lines",
15
baseStyles: `
16
.blurred:before {
17
content: "";
18
position: absolute;
19
top: 0;
20
left: 0;
21
width: 100%;
22
height: 100%;
23
background: rgba(255, 255, 255, 0.1);
24
z-index: 1;
25
backdrop-filter: blur(4px);
26
}
27
28
.blurred {
29
position: relative;
30
}
31
`,
32
hooks: {
33
preprocessMetadata: ({ codeBlock: { metaOptions, props } }) => {
34
// Transfer meta options (if any) to props
35
const range = metaOptions.getRange("blurredLines");
36
37
if (range) {
38
// Init hashmap to store all blurred line numbers
39
const blurredLines: Record<number, boolean> = {};
40
// Match all pairs with regex
41
const matches = range.matchAll(/(\d+-\d+)|(\d+(?!-))/g);
42
for (const m of matches) {
43
const value = m[0];
44
45
// Check if it is a range
46
if (value.indexOf("-") > -1) {
47
const [start, end] = value.split("-").map(Number);
48
if (end <= start) throw new Error("Invalid range");
49
for (let i = start; i <= end; i++) {
50
blurredLines[i] = true;
51
}
52
continue;
53
}
54
// Else it is a single number
55
blurredLines[Number(value)] = true;
56
}
57
58
const map: Record<number, boolean> = {};
59
60
props.blurredLines = blurredLines;
61
}
62
},
63
postprocessRenderedLine: ({ codeBlock, renderData, lineIndex, line }) => {
64
if (codeBlock.props.blurredLines) {
65
// use lineIndex to check if it should be blurred
66
if (lineIndex + 1 in codeBlock.props.blurredLines) {
67
// add blurred class to the line
68
addClassName(renderData.lineAst, "blurred");
69
}
70
}
71
},
72
},
73
};
74
}

And in config:

astro.config.ts
1
import expressiveCode from "astro-expressive-code";
2
import { pluginBlurLines } from "./plugins/expressive-code-blur";
3
4
// in config
5
expressiveCode({
6
plugins: [... pluginBlurLines() ...],
7
}),

Full API example is referenced here

To add multiple themes, I just modified my script that was setting the main theme to support dark/light modes with another data attribute on <html>:

1
const getCodeTheme = (theme) => {
2
const codeTheme = theme in darkThemes ? "ayu-dark" : "rose-pine-dawn";
3
return codeTheme;
4
};
5
6
// when setting the theme
7
{
8
const theme = getDocumentTheme();
9
if (!theme) return;
10
const codeTheme = getCodeTheme(theme);
11
// set code-theme data attribute
12
document.documentElement.dataset.codeTheme = codeTheme;
13
localStorage.setItem(THEME_KEY, theme);
14
}

expressive-code gave me highlights, line numbers, diffing, copy button and pretty wrappers for blocks instantly without any investment, which I liked and went along with it. The library may miss some extended features, but it is amendable with plugin API and explanations the docs provide.

One more thing about code blocks: They have Jetbrains Mono font, which I think makes code a bit more readable, provided by fontsource like so:

/src/blog/[...slug].astro
1
---
2
import "@fontsource-variable/jetbrains-mono";
3
---

DIY noodles

Caloric-intake drama

For making app interactive, I went with writing some vanilla JavaScript, because I wanted to freshen my knowledge and make things as primitive as they could be, and also the bundle size demon had me in his hand.

As most frontend people are obsessed now with the problem of JavaScript bundle size, I thought it would be important to handle this most efficiently. I wanted to not include any more logic than I had to, and would decline any attempt of sane thoughts to persuade me into using a UI library. But you will see what catharsis few paragraphs ahead.

Withdrawal symptoms

One the most complex interactions was I couldā€™ve probably built it much faster using any UI library, and all the roundtrips to dom with querySelector, getAttribute and others were a bit lost in my head.

It took me some time to research on the needed plugins to make headings and links to render and also to tweak everything in a <script> tag. Some of that was from unnecessary complexity I made up as I tried to make several different implementations (as in highlight just one or several headings, what styles to add and so on), and in the end I went the simplest route:

  • Highlight every section (provided wrapper by rehype-sectionize) even remotely on the screen with IntersectionObserver while account for headings margin like so:
1
const header = document.querySelector("header");
2
const headerHeight = header ? header.offsetHeight : 0;
3
4
const observer = new IntersectionObserver(
5
(sections) => {
6
sections.forEach((section) => {
7
const heading = section.target.querySelector("h2, h3, h4, h5, h6");
8
if (!heading) return;
9
10
const id = heading.getAttribute("id");
11
const link = document.querySelector(`#toc li a[href="#${id}"]`);
12
13
if (!link) return;
14
15
link.classList[section.isIntersecting ? "add" : "remove"]("seeing");
16
});
17
},
18
{
19
rootMargin: `-${headerHeight}px 0px 0px 0px`,
20
})
21
22
const sections = document.querySelectorAll(".prose section");
23
sections.forEach((section) => {
24
observer.observe(section);
25
});
26
);
  • Add intercepting smooth scroll to the links:
1
const links = document.querySelectorAll(
2
"#toc a[href^='#']",
3
) as NodeListOf<HTMLAnchorElement>;
4
5
for (const link of links) {
6
link.addEventListener("click", (event) => {
7
event.preventDefault();
8
const target = document.querySelector(link.href);
9
target?.scrollIntoView({ behavior: "smooth", block: "start" });
10
});
11
}
  • On mobile, do not add anything and render the block above the article. Maybe in the future I will add a button in the header to show it full-screen, but now it is just there.

As you can see now, this third block didnā€™t get into production, and thereā€™s a button at the top of the screen. When writing this article, Iā€™ve been looking at other peopleā€™s blogs, and noticed that my design was very bloated, and I wanted to make it simpler. I made a button to close TOC so that it wouldnā€™t bother userā€™s screen when it is not needed.

From that I figured that if you donā€™t know something, you will have a ton of ideas (or none, actually) how to build it, and may go into a deep rabbit hole of multiple decisions that do not work together. This especially hurts you if thereā€™s many unknown things, so you get lost even more easily.

So, hereā€™s what Iā€™ve learned from that:

  • See other people work and learn from it
  • Plan, always!
  • Dont break the plan just because you had a new idea šŸ™„ Think it thoroughly first

Sugarless cola

At the same time, I couldā€™ve used preact while having access to all React APIā€™s just for 5-10kb final production bundle. This is achieved through preact/compat compatibility layer described here.

While React is about 100kb in prod, this tradeoff wouldā€™ve been minimal and less! Svelte could also be an option, because it compiles to pure native JavaScript with a client payload of less than 10 kilobytes even! Using those tools, I couldā€™ve made all these script blocks much easier with inbuilt reactivity. So, using a framework is not always an overhead! Sometimes you donā€™t want to querySelector all your stuff..

Pushing that aside, when I was making , I had wanted to finally use some preact. I heard about nanostores and their lightweightness, and thought why not. But in the end, I figured having to import state management into my script tags everywhere was not much of a goal as I couldā€™ve perfectly used MutationObserver the same way I did it with theme picker. I just added a loop and switch statement to check what attribute changed:

1
const observer = new MutationObserver((records) => {
2
for (const record of records) {
3
console.log(record.attributeName);
4
switch (record.attributeName) {
5
case "data-theme": {
6
const theme = getDocumentTheme();
7
if (!theme) return;
8
const codeTheme = getCodeTheme(theme);
9
document.documentElement.dataset.codeTheme = codeTheme;
10
localStorage.setItem(THEME_KEY, theme);
11
}
12
13
case "data-config_width": {
14
const configWidth = document.documentElement.dataset.config_width;
15
if (!configWidth) return;
16
localStorage.setItem(configKeys.width, configWidth);
17
}
18
19
case "data-config_font-size": {
20
const configFontSize = document.documentElement.dataset["config_fontSize"];
21
if (!configFontSize) return;
22
localStorage.setItem(configKeys.fontSize, configFontSize);
23
}
24
}
25
}
26
});

The html part is about the same as ThemeDropdown, just render all options and assign dataset to them:

1
---
2
import { Icon } from "@/components/react/Icon";
3
import { cn } from "@/lib/cn";
4
import { FontSizeLabelsMap, WidthSizeLabelsMap } from "./prose-config";
5
6
interface Props {
7
class?: string;
8
contentClassname?: string;
9
}
10
11
const { class: className, contentClassname } = Astro.props;
12
---
13
14
<div class={cn("dropdown dropdown-bottom dropdown-left", className)}>
15
<div
16
tabindex="0"
17
role="button"
18
class="btn btn-ghost btn-sm"
19
aria-labelledby="prose-config-button"
20
>
21
<Icon className="text-xl la--cog" />
22
<span class="sr-only" id="prose-config-button"> Open text configuration </span>
23
</div>
24
<ul
25
tabindex="0"
26
class={cn(
27
"dropdown-content z-[1] w-[320px] flex flex-col gap-6 rounded-box mt-4 bg-base-100 p-3 shadow ring-2 ring-primary-content transition-all",
28
contentClassname,
29
)}
30
>
31
<li>
32
<span class="mb-2 flex items-center gap-2 text-lg"
33
><span class="font-bold"> Text width</span>
34
<Icon className="align-middle la--text-width" /></span
35
>
36
<div class="flex flex-wrap items-center gap-2">
37
{
38
Object.entries(WidthSizeLabelsMap).map(([key, label]) => (
39
<button data-value={key} class={cn("config-width-btn btn btn-ghost btn-sm")}>
40
{label}
41
</button>
42
))
43
}
44
</div>
45
</li>
46
47
<li>
48
<!-- The same for font sizes -->
49
</li>
50
</ul>
51
</div>

Writing logic was very straightforward and repetetive:

1
const widthButtons = document.querySelectorAll(
2
".config-width-btn",
3
) as NodeListOf<HTMLButtonElement>;
4
for (const btn of widthButtons) {
5
btn.addEventListener("click", () => {
6
const value = btn.dataset.value;
7
document.documentElement.dataset.config_width = value;
8
});
9
}
10
11
const fontSizeButtons = document.querySelectorAll(
12
".config-fontSize-btn",
13
) as NodeListOf<HTMLButtonElement>;
14
for (const btn of fontSizeButtons) {
15
btn.addEventListener("click", () => {
16
const value = btn.dataset.value;
17
document.documentElement.dataset.config_fontSize = value;
18
});
19
}

And then Iā€™ve screwed some css variables and have gotten pretty simple solution.I thought it would be okay to use is:global, since ConfigDropdown appears everywhere in navbar anyway, and it should be accompanied by them. It was easier to style buttons to be btn-active with that. Tailwind is complicated in it, and I couldnā€™t make it work however I tried, even putting class="group/html" at <html>. It is pretty repetetive, but very simple and contained in one place:

1
<style is:global>
2
html[data-config_width="sm"] {
3
--article-width: 55ch;
4
5
.config-width-btn[data-value="sm"] {
6
@apply btn-active;
7
}
8
}
9
10
html[data-config_width="def"] {
11
--article-width: 65ch;
12
13
.config-width-btn[data-value="def"] {
14
@apply btn-active;
15
}
16
}
17
18
html[data-config_width="lg"] {
19
--article-width: 75ch;
20
21
.config-width-btn[data-value="lg"] {
22
@apply btn-active;
23
}
24
}
25
26
html[data-config_font-size="sm"] {
27
.article-body {
28
@apply prose-sm;
29
}
30
31
.config-fontSize-btn[data-value="sm"] {
32
@apply btn-active;
33
}
34
}
35
36
html[data-config_font-size="def"] {
37
.config-fontSize-btn[data-value="def"] {
38
@apply btn-active;
39
}
40
}
41
42
html[data-config_font-size="lg"] {
43
.article-body {
44
@apply prose-lg;
45
}
46
47
.config-fontSize-btn[data-value="lg"] {
48
@apply btn-active;
49
}
50
}
51
</style>

So, when choosing a technology, donā€™t bother much (unless explicitly very much required) with all the intricacies of bundle size, because you must make it simple and get it working first. And when youā€™re done, you can optimize it anyways.

Golden tickets out of chocolate

Letā€™s talk about something else..

For users to discover my content, I wanted to make two features:

  • A paginated blog to ā€œencounterā€ content, which you could find at this page
  • A global search bar to ā€œprecisely look forā€ content

It would be openable from anywhere and would give user ability to search for any content or keyword.

A global search might be done in backend, but when you have few articles that do not have much content, I donā€™t think the investment in Algolia is worth it.

While Astro provides API for pagination out of the box, the search is handled by separate libraries. Coming from Astro docs, I found myself before two choices: pagefind and fuse.js.

I have tried Fuse.js and it worked well, but pagefind seemed more feature-rich: it provided exact text excerpts, while with Fuse I was limited to just showing simple cards, and also filtering, sorting and so on.

pagefind is a Rust-based static search artifacts generator. It makes resources to search at build-time and an API to call this search from the client. While blazingly-fast sounds cool, at first, I find it difficult because of the cumbersome way to load WASM bundle, artifacts and client-side API that is working through window binding. First, you need to generate it all, then put it in a public folder so you can import js client and so on..

Gladly, there was this library and it was possible to integrate pagefind with Astro in few clicks. It provides a Vite plugin to incorporate already built pagefind index into dev runtime. And images donā€™t work in dev, but its amendable.

I had some tinkering, mostly struggling with indexing & assets loading. For the lib to work, you need to build your project first, and then it would load pagefind script with Vite.

For pagefind to index my pages correctly (I wanted it to index only blog posts), I added data-pagefind-body to my blog post container, so only blog post pages would be indexed:

1
<div data-pagefind-body>
2
<h1 class="mx-auto mb-4 mt-4 w-fit text-6xl font-extrabold">
3
{post.data.title}
4
</h1>
5
<!-- ... -->

And with that I had to hide code blocks from being indexed, because they show ugly in the search results and add line numbers to the index. I think it is enough to have search terms as a text.Here, expressive-codeā€™s plugin API came to rescue again:

1
import { type ExpressiveCodePlugin } from "@expressive-code/core";
2
import { setProperty } from "@expressive-code/core/hast";
3
4
export interface PluginIgnoreIndex {}
5
6
declare module "@expressive-code/core" {
7
export interface ExpressiveCodeBlockProps extends PluginIgnoreIndex {}
8
}
9
10
export function pluginIgnoreIndex(): ExpressiveCodePlugin {
11
return {
12
name: "Ignore index",
13
14
hooks: {
15
postprocessRenderedBlock: ({ renderData }) => {
16
// I hid all code blocks, because article's text is sufficient to note about technology
17
// plus to that, code blocks are not much readable in search results
18
setProperty(renderData.blockAst, "data-pagefind-ignore", "all");
19
},
20
},
21
};
22
}

To render <Search> component, I made two components: <SearchTrigger> and <SearchDialog>. The first one is a button that opens the search modal, which I put in the navbar, while the second one is a modal that is opened with dialog element.

Here, I made a mistake like the one with the <head> before. At first, I placed dialog in the body, but in the wrong place: right below my navbar. It was a critical mistake, as it slowed initial load time immensely, so I moved it lower, below <Footer>:

1
<body class="flex flex-col px-3 md:px-1">
2
<Navbar>
3
<slot name="navbar" slot="default" />
4
</Navbar>
5
6
<main class={cn("h-0 flex-1", mainClassname)}>
7
<slot />
8
</main>
9
<Footer />
10
11
<SearchDialog /> <!-- Here -->
12
</body>

Remember the part where I told you to wait few paragraphs for follow-up? Well, there it is. Apparently, <Search> component was importing CSS and JavaScript bundle of @pagefind/default-ui library statically. In dev mode it meant that in network tab a row with almost 1 megabyte of JavaScript would appear.

In production, it would be about 200kb, but the library loads @pagefind/default-uiā€™s css always, which added some kilobytes to CSS bundle. I had ripped my hair out since my perfectionism and child-playing got the best of me, and even wrote a Vite plugin to remove css and js from @pagefind/default-ui package when the app started and loaded it later with import() statements! But that made everything worse and was sure as hell not worth it šŸš‘ to save few kb.

The punchline is that when I was almost trying to publish the website and release the article, tweet and post to reddit like a newly famous internet celebrity, I noticed that my dialog with search was completely mangled on mobile and I couldnā€™t even use it. So, I spent some three or four hours to remove an issue that wasnā€™t there, while my app wasnā€™t working! Ridiculous!

Of course, such excitement could spur productivity and enthusiasm towards something new, but its important to not overdo it. Do not remove your axios when starting a new project because youā€™ve heard of ky or redaxios, you could always replace it later. Do not start a project in a brandy-new Bun environment with fully-fledged alpha framework running its campaign on Tweeter. Think about what brings value to the user of your app, and after youā€™ve delivered that, sprinkle it with any confetti you want. At least, thatā€™s what I would tell myself every day from now on! šŸ˜¹

Storing wine

When I was looking for inspiration, I stumbled again upon Josh Comeauā€™s website. It had this heart button with sound effects and my urge to steal was like never before. You may have seen the result of this, angry, or maybe happy now. to see him! My personal orange! šŸˆ

This guy is needed for the purpose of bullying out like clicks from people, there is no mistake. But how to store anything over one client session in a static website? There were two ways.

One way was to convert my app to a hybrid one. That meant setting a config option like so:

astro.config.ts
1
output: "hybrid",

With that turned out, Astro would spit Node scripts with web-server instead of purely static HTML pages all on their own, which would grant me an opportunity to setup a database connector and not leave the boundaries of my project to make a separate backend. Hereā€™s the file structure for that:

Hybrid output file structure

I implemented all that, and it was working nice: API routes, Prisma, cool! But, I noticed a weird bug or maybe just a consequence of using Node builds: My app was loading so much CSS! I was wondering why my app would load 130kb of it, while Astro stated that Netflixā€™s Tailwind website only weighs eight.

Here, I took the route of running away from the problem instead of understanding it. I outsourced trying to figure it out when I created a topic in Astro discord server and foolishly started playing around again. I really wanted to experiment with making a free separate backend! And that child spirit got the best of me.

The issue was that Astro does not compress static assets while running in non-static output modes. I think thatā€™s because it is more reasonable to use hosting or reverse-proxy resources to gzip prebuilt content itself. So, in production environment my app was the same size in all modes.

I will declare my journey with separate backend anyway as it is not a long one, plus includes interesting shenanigans with Prisma, Kysely and Next.js.

Poor manā€™s bread

I hosted it all on Vercel, because it is easyto publish any hobby project of yours for free there. Just host on Github, link it in dashboard and get free CI/CD, probably not the worst domain name according to your project name if its unique enough (last minute I decided to grab the current domain at Namecheap, since it was, well, cheap), SSL is handled for you and so on. Free plan is awesome!

And as I found out, Vercel could host your backend as Serverless functions. You may make a Node.js project from their template or you can just deploy a Next.js app with Route handlers. These are just HTTP verbs exported from app/api folder in the latest Next versions and you could write them like so:

app/api/route.ts
1
export const dynamic = 'force-dynamic'
2
export async function GET(request: Request) {
3
return new Response("hello world")
4
}

So, opening /api route in the app would return hello world.

It opens a possibility to host a free backend, but with a catch. Serverless means that there are some server resources being allocated and deallocated depending on the load automatically. Vercel would boot containers when request comes up and remove them after a time when there is no more requests coming up.

Serverless approach holds many benefits, but some disadvantages too. For one, to boot up new containers with long startup times, you would need to wait for a longer cold start times. And as Iā€™ve heard, Prisma, my beloved ORM is not good at those as it ships with Rust binary. Prisma team have fixed some of it, I guess, but I wanted to use some other ORM instead.

For that I choose kysely which is a very lightweight and handy to use SQL query-builder. It doesnā€™t manage migrations, so I have setup Prisma alongside it too just for the purpose of syncing schema and client types. Gladly, there is a generator for making Kysely types out of Prisma schemas.

Kysely queries look like this, enabling very flexible query building, even with complex joins, aggregate functions and more:

1
const updatePost = async (post: Updateable<Post>, postId: string) => {
2
return db
3
.updateTable("posts")
4
.set(post)
5
.where("id", "=", postId)
6
.returningAll()
7
.executeTakeFirst();
8
};

Locally I used docker-compose to run postgres easily with one command:

1
services:
2
db:
3
image: postgres:15.1
4
restart: always
5
environment:
6
POSTGRES_USER: postgres
7
POSTGRES_PASSWORD: postgres
8
POSTGRES_DB: postgres
9
ports:
10
- 5432:5432
11
volumes:
12
- db_data:/var/lib/postgresql
13
healthcheck:
14
test: ["CMD-SHELL", "pg_isready"]
15
interval: 10s
16
timeout: 5s
17
retries: 5
18
19
volumes:
20
db_data:

For remote hosting I choose supabase as it provides free hosting for postgres. I couldā€™ve chosen other providers, even Vercel itself, but I didnā€™t see any difference anyway, since all I want is just a free db for few records tops.

To glue it all together, I also had to switch default locations of supabase and both my hosts on Vercel to one region, so there would not be a lot of latency.

I wouldnā€™t go into a complete implementation of all that, since its a pretty basic stuff. If youā€™re interested, you can check out the code for the backend in this repo.

Gordon Ramsay show

The last partā€™s gonna be about cosmetic and useful things I screwed into the website as a icing on the cake.

I was intersted to dive deep into documentation for SEO. For a long time I thought it to be very abstract and far away from me, but as Iā€™d seen my knowledge gap, I wanted to close it, just a bit. So, hereā€™s the new tools Iā€™ve learned about:

  • Performance and adaptivity matters most. At final steps of this project, I have even removed images from blog posts, because it smoothed loading times so much. Blogs should not be about much cosmetic, but rather about content. And as it is very easy to make text content fluid and very fast in HTML, it is also easy to stamp a lot of features to make it load 10 seconds and fail all Lighthouse audits. Look at this fella. It is sure perfect.

  • Do some testing with Lighthouse. It has helped me to figure out where I messed up my accessibility. For example links, which is a very important part of SEO ranking your site higher. I havenā€™t included labels for screenreaders on icon buttons, have used bad link names, didnā€™t add rel="nofollow" on external ones, and so on. It also pointed some performance issues. And at the end it gave me some dopamine with all 100ā€™s scores šŸ†’ But do not overdo it. Sometimes its better to not have it all perfect.

Lighthouse scores

  • I did not reap those accessibility rewards, and I donā€™t know anyone personally who would use a screen reader, but at least consider another user who would use tab key for the sake of switching between buttons. Do not make links as <button>s. Do not make buttons as <div>s without tabindex="0". And please, use <dialog> for modals, or at least think about what would happen, if user presses tab inside of them. Also, donā€™t forget about contrast, it is quite important to read, yā€™know.

  • Look at network tab and performance tabs. Slow down your requests to slow 4g or even 3g and check how your site behaves under these conditions. Does it load fast, what content pops up first? Have you added loading="eager" to your big image so that there would not be a huge Largest Contentful Paint? In performance tab you could even slow down processor speeds artificially to see how website would perform on lower-grade devices like mobiles.

Performance tab

  • Make unique content. Some people claim AI can write for them - itā€™s cool, but you should also write yourself, improve and cultivate style and taste. If you can't think of something to write about something, then you should not be writing. Do not write just for the sake of showing anything at all. At least tell how you feel. For example, after this Daniel Roeā€™s short story I got pretty sad, but understood many things. So he healed me a bit, even though he didnā€™t know me. Thatā€™s probably because he healed himself with it at the time of writing, and thatā€™s what makes this post real.

  • Add RSS feed. I like them. And they are everywhere. Its not a mail newsletter in a sense that its not so advanced that you would track your readerā€™s email and audience count, but it is simple and many people use them to get updates about your blog.

  • Spend time delivering features and gathering feedback. It is the most important step. You are making content for others, and as youā€™re writing and (of course you do) putting yourself in shoes of your audience, you must give them something worthwhile instead of polishing the button for 10 hours that would break on the next commit anyway because youā€™re human.

  • Do not forget about important meta tags like <title> and <description>. Research how favicons work (but you will be terrified!). Also, you could add OpenGraph tags for making articles attractive for social networks. These are added with og:[image] meta tags, for example: <meta property="og:title"> My great post! </meta>.

Afterparty

In the end, I want to say that making this blog and writing this article was quite unveiling to me in many senses. I had seen once again in much bigger light that my obssessions about performance and idealism take the best of me. All the preliminary optimizations were for trying to conceal my imperfections and inability to make every feature and make it perfect as I am limited.

After that, I have acknowledged that building something and investing yourself in life as a participant makes you better human being just because you have to see who you really are, which you maybe wonā€™t because you donā€™t want to and thereā€™s nothing to push you to it. Setting goals, keeping promises given to yourself, doing real work, following the idea that your mind originated to its end - thatā€™s what Iā€™ve been lacking greatly as a developer.

I have tried making some apps, but infinitely, I have become a slouch who was making forms over and over and over again without taking greater challenges or abandoning my dreams when they became a little bit harder than I expected them to. Of course, there were challenges, and I have grown from them too, but while I didnā€™t see who am I turning into - folder mover (architect), button painter (ui designer), frameworkista (qualified specialist), I actually became worse. When simplicity was required, I was trying to over-complicate, because I thought thatā€™s what smart developers do. Like that picture:

Simplicity's Bell curve

But smart developers actually make everything simple. You do not become a senior out of making another cool Singleton Factory Static Abstract Facade Dependency Injected Service AWS Serverless Kafka provider. Seniors want it to be just a little pack of ifā€™s and whileā€™s, because they already understood that you could write infinite amount of code, but only so much of it matters.

Astro is a great framework and a good learning resource for those who may find themselves in web development space. If you open the docs open-minded and build something yourself, try to see what tools you use, what code you write and how it actually works under the hood, what assets load and not, why is it better to have it this way. You will feel much better. Donā€™t hurry - and it will pay out in the end šŸ±

Thanks for reading!