upgrade deps to latest & migrate to astro:assets Image
- bump astro 5→6, @astrojs/mdx 4→6, astro-seo 0.8→1.1, sharp 0.33→0.35, plus minor updates - move local images from public/ to src/assets/ - replace <img> with <Image> from astro:assets (inferSize for remote URLs) - content schema uses image() helper for local covers - eager-load above-the-fold images (article covers, hero avatars)
@@ -7,6 +7,14 @@ import icon from "astro-icon";
|
||||
export default defineConfig({
|
||||
site: "https://mariosant.dev",
|
||||
integrations: [mdx(), sitemap(), icon()],
|
||||
image: {
|
||||
domains: [
|
||||
"images.unsplash.com",
|
||||
"app.ghostwriter.rocks",
|
||||
"990141.apps.zdusercontent.com",
|
||||
"cdn.livechat-files.com",
|
||||
],
|
||||
},
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
|
||||
26
package.json
@@ -11,20 +11,20 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.2.0",
|
||||
"@astrojs/rss": "^4.0.11",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@fontsource-variable/inter": "^5.2.5",
|
||||
"@iconify-json/heroicons": "^1.2.1",
|
||||
"@astrojs/mdx": "^6.0.3",
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
"@astrojs/sitemap": "^3.7.3",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@iconify-json/heroicons": "^1.2.3",
|
||||
"@iconify-json/hugeicons": "^1.2.29",
|
||||
"@iconify-json/mynaui": "^1.2.5",
|
||||
"@iconify-json/simple-icons": "^1.2.28",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
"astro": "^5.5.2",
|
||||
"@iconify-json/mynaui": "^1.2.17",
|
||||
"@iconify-json/simple-icons": "^1.2.86",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"astro": "^6.4.7",
|
||||
"astro-icon": "^1.1.5",
|
||||
"astro-seo": "^0.8.4",
|
||||
"sharp": "^0.33.5",
|
||||
"tailwindcss": "^4.0.14"
|
||||
"astro-seo": "^1.1.0",
|
||||
"sharp": "^0.35.1",
|
||||
"tailwindcss": "^4.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
1418
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 599 KiB After Width: | Height: | Size: 599 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -1,5 +1,10 @@
|
||||
---
|
||||
import PreviousRole from "@/components/previous-role.astro";
|
||||
import lateralusLogo from "@/assets/lateralus-logo.jpeg";
|
||||
import celonisLogo from "@/assets/celonis-logo.webp";
|
||||
import lensesLogo from "@/assets/lenses-logo.webp";
|
||||
import theChatShopLogo from "@/assets/thechatshop-logo.jpg";
|
||||
import commversionLogo from "@/assets/commversion-logo.webp";
|
||||
---
|
||||
|
||||
<section class="flex flex-col gap-5 p-4 sm:p-6 lg:p-8 max-w-3xl mx-auto w-full">
|
||||
@@ -7,7 +12,7 @@ import PreviousRole from "@/components/previous-role.astro";
|
||||
|
||||
<PreviousRole
|
||||
company="Lateralus Ventures"
|
||||
logo="/lateralus-logo.jpeg"
|
||||
logo={lateralusLogo}
|
||||
href="https://lateralus.ventures"
|
||||
>
|
||||
Frontend work on Ask Chief, building interfaces for AI-powered products that needed to feel simple
|
||||
@@ -16,7 +21,7 @@ import PreviousRole from "@/components/previous-role.astro";
|
||||
|
||||
<PreviousRole
|
||||
company="Celonis"
|
||||
logo="/celonis-logo.webp"
|
||||
logo={celonisLogo}
|
||||
href="https://celonis.com"
|
||||
>
|
||||
Building out analytical interfaces for OCPM, a shared component library, and tooling that made it
|
||||
@@ -25,21 +30,21 @@ import PreviousRole from "@/components/previous-role.astro";
|
||||
|
||||
<PreviousRole
|
||||
company="Lenses.io"
|
||||
logo="/lenses-logo.webp"
|
||||
logo={lensesLogo}
|
||||
href="https://lenses.io"
|
||||
>
|
||||
Product interfaces for SQL Processors and Data Catalog, a shared component library, and developer
|
||||
tooling including the CLI.
|
||||
</PreviousRole>
|
||||
|
||||
<PreviousRole company="The Chat Shop" logo="/thechatshop-logo.jpg">
|
||||
<PreviousRole company="The Chat Shop" logo={theChatShopLogo}>
|
||||
Streaming data infrastructure built from scratch, along with a processing pipeline that kept delivery
|
||||
running reliably at scale.
|
||||
</PreviousRole>
|
||||
|
||||
<PreviousRole
|
||||
company="Commversion"
|
||||
logo="/commversion-logo.webp"
|
||||
logo={commversionLogo}
|
||||
href="https://commversion.com"
|
||||
>
|
||||
Customer delivery platform and its public-facing side, plus InstantConnect, taken from design through
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import Button from "@/components/button.astro";
|
||||
import Link from "@/components/link.astro";
|
||||
import mariosant from "@/assets/mariosant.webp";
|
||||
---
|
||||
|
||||
<header class="flex flex-col md:gap-12 gap-5 p-4 sm:p-6 lg:p-8 max-w-3xl mx-auto w-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" aria-label="Home">
|
||||
<img
|
||||
<Image
|
||||
src={mariosant}
|
||||
alt="Marios Antonoudiou"
|
||||
src="/mariosant.webp"
|
||||
width="64"
|
||||
height="64"
|
||||
width={64}
|
||||
height={64}
|
||||
class="w-16 h-16 rounded-full border border-gray-200 dark:border-gray-800 dark:bg-slate-300 block"
|
||||
loading="eager"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import { getCollection } from "astro:content";
|
||||
import { formatDoMMMMYYYY } from "@/utils/date";
|
||||
|
||||
@@ -12,7 +13,10 @@ if (!article) {
|
||||
}
|
||||
|
||||
const cover = article.data.coverImage;
|
||||
const coverUrl = cover.url.replace("w=1287&h=600", "w=300&h=300");
|
||||
const isRemote = typeof cover.url === "string";
|
||||
const coverSrc = isRemote
|
||||
? cover.url.replace("w=1287&h=600", "w=300&h=300")
|
||||
: cover.url;
|
||||
|
||||
const body = (article.body ?? "").trim();
|
||||
const excerpt = body.length > 150 ? `${body.slice(0, 150)}…` : body;
|
||||
@@ -30,10 +34,11 @@ const excerpt = body.length > 150 ? `${body.slice(0, 150)}…` : body;
|
||||
<div class="flex gap-6 items-start flex-row-reverse">
|
||||
{
|
||||
cover.url && (
|
||||
<img
|
||||
<Image
|
||||
class="w-24 h-24 object-cover rounded-lg shrink-0"
|
||||
src={coverUrl}
|
||||
src={coverSrc}
|
||||
alt={article.data.title}
|
||||
inferSize={isRemote}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
---
|
||||
import { Image, type ImageMetadata } from "astro:assets";
|
||||
|
||||
export interface Props {
|
||||
logo: string;
|
||||
logo: ImageMetadata | string;
|
||||
href?: string;
|
||||
company: string;
|
||||
}
|
||||
|
||||
const { logo, href, company } = Astro.props;
|
||||
const isRemote = typeof logo === "string";
|
||||
---
|
||||
|
||||
<div class="flex gap-5">
|
||||
{
|
||||
href ? (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" class="block shrink-0">
|
||||
<img
|
||||
<Image
|
||||
src={logo}
|
||||
alt={company}
|
||||
inferSize={isRemote}
|
||||
width={isRemote ? undefined : 50}
|
||||
height={isRemote ? undefined : 50}
|
||||
class="block rounded min-w-[50px] w-[50px] h-[50px] object-contain bg-white"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<img
|
||||
<Image
|
||||
src={logo}
|
||||
alt={company}
|
||||
inferSize={isRemote}
|
||||
width={isRemote ? undefined : 50}
|
||||
height={isRemote ? undefined : 50}
|
||||
class="block rounded min-w-[50px] w-[50px] h-[50px] object-contain bg-white shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
---
|
||||
import { Image, type ImageMetadata } from "astro:assets";
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
image: string;
|
||||
image: ImageMetadata | string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const { title, image, url } = Astro.props;
|
||||
const isRemote = typeof image === "string";
|
||||
---
|
||||
|
||||
<div class="flex overflow-auto gap-x-5 md:gap-2 items-center md:items-start md:flex-col md:border md:rounded-lg md:border-gray-200 md:p-4 md:dark:border-slate-600">
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" class="shrink-0">
|
||||
<img
|
||||
<Image
|
||||
src={image}
|
||||
alt={title}
|
||||
inferSize={isRemote}
|
||||
width={isRemote ? undefined : 64}
|
||||
height={isRemote ? undefined : 64}
|
||||
class="block min-w-[50px] w-[50px] md:w-16 h-[50px] md:h-16 object-contain bg-white rounded"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import Link from "@/components/link.astro";
|
||||
import mariosant from "@/assets/mariosant.webp";
|
||||
|
||||
const path = Astro.url.pathname;
|
||||
const isHome = path === "/" || path === "";
|
||||
@@ -9,11 +11,11 @@ const isArticles = path === "/articles" || path.startsWith("/articles/");
|
||||
<div class="sticky top-0 z-10 border-b border-gray-200 dark:border-slate-600 bg-white/85 dark:bg-slate-800/90 backdrop-blur-sm">
|
||||
<nav class="grid grid-cols-2 items-center gap-3 !py-2 max-w-3xl mx-auto w-full p-4 sm:p-6 lg:p-8">
|
||||
<Link href="/" class="flex gap-3 items-center" variant="default">
|
||||
<img
|
||||
<Image
|
||||
src={mariosant}
|
||||
alt="Marios Antonoudiou"
|
||||
src="/mariosant.webp"
|
||||
width="40"
|
||||
height="40"
|
||||
width={40}
|
||||
height={40}
|
||||
class="w-10 h-10 rounded-full border border-gray-200 dark:border-gray-800 dark:bg-slate-300 block"
|
||||
loading="eager"
|
||||
/>
|
||||
|
||||
@@ -3,16 +3,17 @@ import { glob } from "astro/loaders";
|
||||
|
||||
const articles = defineCollection({
|
||||
loader: glob({ pattern: "**/*.md", base: "./src/content/articles" }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
date: z.coerce.date(),
|
||||
description: z.string().optional(),
|
||||
coverImage: z.object({
|
||||
author: z.string(),
|
||||
authorUrl: z.string().url().optional(),
|
||||
url: z.string(),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
date: z.coerce.date(),
|
||||
description: z.string().optional(),
|
||||
coverImage: z.object({
|
||||
author: z.string(),
|
||||
authorUrl: z.string().url().optional(),
|
||||
url: z.union([image(), z.string().url()]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { articles };
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Smooth Scrolling in Agentic Chat Interfaces
|
||||
date: 2025-09-15
|
||||
coverImage:
|
||||
author: mariosant
|
||||
url: "/agentic-ui.png"
|
||||
url: "../../assets/agentic-ui.png"
|
||||
---
|
||||
|
||||
When building conversational UIs, small interaction details can make a huge difference in how natural the experience feels. Recently, while working on an agentic chat interface, I ran into a deceptively simple requirement:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import { getCollection, render } from "astro:content";
|
||||
import Layout from "@/layouts/Layout.astro";
|
||||
import Link from "@/components/link.astro";
|
||||
import Button from "@/components/button.astro";
|
||||
import { formatDoMMMMYYYY } from "@/utils/date";
|
||||
import { articleJsonLd, breadcrumbJsonLd, deriveDescription } from "@/utils/seo";
|
||||
import { articleJsonLd, breadcrumbJsonLd, deriveDescription, imageSrc } from "@/utils/seo";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const articles = await getCollection("articles");
|
||||
@@ -17,7 +18,8 @@ export async function getStaticPaths() {
|
||||
const { entry } = Astro.props;
|
||||
const { Content } = await render(entry);
|
||||
const cover = entry.data.coverImage;
|
||||
const showCover = Boolean(cover?.url);
|
||||
const isRemote = typeof cover.url === "string";
|
||||
const showCover = isRemote ? Boolean(cover.url) : true;
|
||||
const description = entry.data.description ?? deriveDescription(entry.body ?? "");
|
||||
---
|
||||
|
||||
@@ -25,7 +27,7 @@ const description = entry.data.description ?? deriveDescription(entry.body ?? ""
|
||||
title={entry.data.title}
|
||||
description={description}
|
||||
ogType="article"
|
||||
ogImage={cover.url}
|
||||
ogImage={imageSrc(cover.url)}
|
||||
ogImageAlt={entry.data.title}
|
||||
jsonLd={[articleJsonLd(entry), breadcrumbJsonLd(entry)]}
|
||||
>
|
||||
@@ -48,13 +50,12 @@ const description = entry.data.description ?? deriveDescription(entry.body ?? ""
|
||||
{
|
||||
showCover && (
|
||||
<figure class="hidden md:block max-w-3xl p-4 sm:p-6 lg:p-8 mx-auto w-full">
|
||||
<img
|
||||
<Image
|
||||
src={cover.url}
|
||||
alt={entry.data.title}
|
||||
height="1000"
|
||||
width="1700"
|
||||
inferSize={isRemote}
|
||||
class="rounded-lg w-full h-auto"
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
/>
|
||||
{cover.authorUrl && (
|
||||
<a
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import { getCollection } from "astro:content";
|
||||
import Layout from "@/layouts/Layout.astro";
|
||||
import { formatDoMMMMYYYY } from "@/utils/date";
|
||||
@@ -20,14 +21,18 @@ const articles = (await getCollection("articles")).sort(
|
||||
|
||||
<section class="grid grid-cols-12 gap-8 max-w-3xl p-4 sm:p-6 lg:p-8 mx-auto w-full">
|
||||
{
|
||||
articles.map((article) => (
|
||||
articles.map((article, index) => {
|
||||
const url = article.data.coverImage.url;
|
||||
const isRemote = typeof url === "string";
|
||||
return (
|
||||
<article class="col-span-12 md:col-span-6 border border-gray-200 dark:border-slate-600 rounded-lg overflow-hidden !p-0">
|
||||
<a href={`/articles/${article.id}`} class="block cursor-pointer">
|
||||
<img
|
||||
<Image
|
||||
class="w-full h-48 object-cover"
|
||||
src={article.data.coverImage.url}
|
||||
src={url}
|
||||
alt={article.data.title}
|
||||
loading="lazy"
|
||||
inferSize={isRemote}
|
||||
loading={index < 2 ? "eager" : "lazy"}
|
||||
/>
|
||||
</a>
|
||||
<div class="grid gap-4 p-4 md:p-6">
|
||||
@@ -52,7 +57,8 @@ const articles = (await getCollection("articles")).sort(
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
);
|
||||
})
|
||||
}
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import rss from "@astrojs/rss";
|
||||
import { getCollection } from "astro:content";
|
||||
import { SITE_URL } from "@/utils/seo";
|
||||
import { deriveDescription } from "@/utils/seo";
|
||||
import { SITE_URL, imageSrc, deriveDescription } from "@/utils/seo";
|
||||
import type { APIContext } from "astro";
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
@@ -22,7 +21,7 @@ export async function GET(context: APIContext) {
|
||||
content: undefined,
|
||||
author: "mariosant@sent.com (Marios Antonoudiou)",
|
||||
categories: [],
|
||||
customData: `<enclosure url="${new URL(entry.data.coverImage.url, context.site ?? SITE_URL).toString()}" type="image/jpeg" />`,
|
||||
customData: `<enclosure url="${new URL(imageSrc(entry.data.coverImage.url), context.site ?? SITE_URL).toString()}" type="image/jpeg" />`,
|
||||
})),
|
||||
customData: `<language>en-us</language>`,
|
||||
stylesheet: false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import type { ImageMetadata } from "astro:assets";
|
||||
|
||||
export const SITE_URL = "https://mariosant.dev";
|
||||
export const TWITTER_HANDLE = "@marios_ant";
|
||||
@@ -42,6 +43,9 @@ export const deriveDescription = (body: string, max = 155): string => {
|
||||
|
||||
type ArticleEntry = CollectionEntry<"articles">;
|
||||
|
||||
export const imageSrc = (img: ImageMetadata | string): string =>
|
||||
typeof img === "string" ? img : img.src;
|
||||
|
||||
export const personJsonLd = (): string =>
|
||||
JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
@@ -62,7 +66,7 @@ export const articleJsonLd = (entry: ArticleEntry): string => {
|
||||
"@type": "Article",
|
||||
headline: entry.data.title,
|
||||
description,
|
||||
image: entry.data.coverImage.url,
|
||||
image: imageSrc(entry.data.coverImage.url),
|
||||
datePublished: entry.data.date.toISOString(),
|
||||
author: {
|
||||
"@id": PERSON_ID,
|
||||
|
||||