Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions astro.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,46 @@
import { defineConfig } from "astro/config";
import { defineConfig, fontProviders } from "astro/config";
import sitemap from "@astrojs/sitemap";
import tailwindcss from "@tailwindcss/vite";
import { readFileSync, readdirSync } from "node:fs";

/**
* Last-modified date for each blog post URL, read from front matter (updatedDate, else pubDate).
* Content collections aren't available in the config, so this parses the Markdown directly.
*/
function blogLastModified() {
const dir = new URL("./src/content/blog/", import.meta.url);
const dates = new Map<string, string>();
for (const file of readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith("_"))) {
const frontMatter = readFileSync(new URL(file, dir), "utf8").split(/^---$/m)[1] ?? "";
const date = (key: string) => frontMatter.match(new RegExp(`^${key}:\\s*"?([0-9-]+)`, "m"))?.[1];
const lastmod = date("updatedDate") ?? date("pubDate");
if (lastmod) dates.set(`/blog/${file.replace(/\.md$/, "")}/`, new Date(lastmod).toISOString());
}
return dates;
}
const blogDates = blogLastModified();

export default defineConfig({
site: "https://www.heyal.co.uk",
integrations: [sitemap()],
integrations: [
sitemap({
serialize(item) {
const lastmod = blogDates.get(new URL(item.url).pathname);
return lastmod ? { ...item, lastmod } : item;
},
}),
],
fonts: [
{
name: "Manrope",
cssVariable: "--font-manrope",
provider: fontProviders.google(),
weights: ["400 800"],
styles: ["normal"],
subsets: ["latin"],
fallbacks: ["ui-sans-serif", "system-ui", "sans-serif"],
},
],
markdown: {
shikiConfig: {
themes: { light: "github-light", dark: "github-dark" },
Expand Down
Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og-default.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
User-agent: *
Allow: /

Sitemap: https://www.heyal.co.uk/sitemap-index.xml
Binary file added src/assets/og-default.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions src/assets/og-default.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ const blog = defineCollection({
heroAlt: z.string().default(""),
/** Personal posts are listed on /blog but not featured on the home page. */
personal: z.boolean().default(false),
/** Topics used to pick related posts and as schema.org keywords. Lowercase, e.g. "eks", "gitops". */
tags: z.array(z.string()).default([]),
/** The service this post relates to; linked from the end of the post. */
service: reference("services").optional(),
/** Groups multi-part posts; parts are linked to each other on the post page. */
series: z.object({ id: z.string(), title: z.string(), part: z.number().int().positive() }).optional(),
/** Post-specific note on what has changed since publication (Markdown not supported). */
Expand Down
2 changes: 2 additions & 0 deletions src/content/blog/bootstrapping-private-eks-argocd.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ description: "Terraform for a private-endpoint EKS cluster, GitHub Actions with
pubDate: 2026-10-17
heroImage: ../../assets/blog/generated/eks-private-argocd.jpg
heroAlt: "A dashed, padlocked VPC boundary around a cluster of connected nodes, with a Git branch pulling into it"
tags: [kubernetes, eks, aws, argo-cd, gitops, terraform, github-actions]
service: infrastructure-and-delivery
---

A Kubernetes API server on the public internet is an attack surface most teams don't need. In regulated environments
Expand Down
7 changes: 4 additions & 3 deletions src/content/blog/civo-my-journey.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
---
title: "Civo Kubernetes KUBE100"
description: "What I have been up to on Civo's managed k3s service"
title: "Civo Kubernetes KUBE100: First Look at Managed k3s"
description: "What k3s is and how it differs from full Kubernetes, my experience on the beta of Civo's managed k3s service, contributing to its app marketplace, and other k3s tooling."
pubDate: 2020-01-07
heroImage: ../../assets/blog/civo-card.jpg
heroAlt: "civo kube100 stickers"
heroAlt: "Civo KUBE100 stickers"
changes: "The KUBE100 beta ended long ago and Civo Kubernetes is now generally available, the Civo CLI has been rewritten, OpenFaaS Cloud has been retired, and k3d's commands have changed (k3d cluster create)."
tags: [kubernetes, k3s, civo, open-source]
---

## The World's First Managed K3s
Expand Down
3 changes: 2 additions & 1 deletion src/content/blog/developing-openfaas.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
---
title: "How To Get Started Contributing to OpenFaaS"
description: "Get started contributing to OpenFaaS"
description: "A step-by-step guide to your first OpenFaaS contribution: setting up Go, Docker and Kubernetes, finding an issue, forking the repository, and opening a pull request."
pubDate: 2020-01-04
heroImage: ../../assets/blog/of-contributing.jpg
heroAlt: "A selection of Open Source stickers and my laptop"
changes: "Several projects listed here (OpenFaaS Cloud, ofc-bootstrap and the open-source inlets) have since been retired, k3d's commands have changed (k3d cluster create), and Go modules have replaced GOPATH. Check the current OpenFaaS contributing guide before starting."
tags: [openfaas, serverless, open-source, go, kubernetes]
---

## New Year New You?
Expand Down
7 changes: 4 additions & 3 deletions src/content/blog/docker-bham-jan.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
---
title: "Docker Birmingham January 2020"
description: "Building docker images for multiple CPU Architectures"
title: "Docker Birmingham January 2020: Multi-Architecture Images with buildx"
description: "From my Docker Birmingham meetup talk: how Docker images and manifests handle CPU architectures, and building images for ARM and x86 with docker buildx."
pubDate: 2020-01-08
heroImage: ../../assets/blog/pi.jpg
heroAlt: "Raspberry Pi computer"
heroAlt: "A Raspberry Pi single-board computer"
changes: "buildx has since become a standard part of Docker and multi-platform builds no longer need experimental features, so the setup described here is simpler today."
tags: [docker, multi-arch, raspberry-pi, community]
---

Every two months a group of us host a meetup focused on Docker. You can find [the meetup page here](https://www.meetup.com/Docker-Birmingham/).
Expand Down
5 changes: 3 additions & 2 deletions src/content/blog/inlets-pro.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
---
title: "Exploring NAT Traversal and Tunnels with Inlets and Inlets Pro"
description: "Give anything a public IP with inlets and inlets pro"
description: "How inlets tunnels give services behind NAT or a firewall a public IP, using inletsctl and the inlets-operator, and when you need inlets PRO for non-HTTP traffic."
pubDate: 2020-01-20
heroImage: ../../assets/blog/inlets-headline.png
heroAlt: "inlets pro headline image"
heroAlt: "The inlets PRO logo"
changes: "The open-source inlets project has since been retired and inlets is now a commercial product, so the open-source client, download URLs, licensing and some commands shown here no longer apply. See docs.inlets.dev for current instructions."
tags: [inlets, networking, tunnels, kubernetes]
---

## Inlets! What is it?
Expand Down
3 changes: 2 additions & 1 deletion src/content/blog/introducing-arkade.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ title: "Introducing Arkade - The Kubernetes app installer"
description: "A CLI with strongly-typed flags to install charts and apps to your cluster in one command."
pubDate: 2020-02-28
heroImage: ../../assets/blog/arkade-headline.png
heroAlt: "arkade headline image"
heroAlt: "The arkade logo"
changes: "arkade has grown well beyond installing apps (arkade get now downloads dozens of CLI tools), and its app list, flags and defaults have changed. Tiller belonged to Helm 2, which reached end of life in 2020."
tags: [arkade, kubernetes, helm, open-source]
---

## Arkade! What is it?
Expand Down
4 changes: 3 additions & 1 deletion src/content/blog/loft-sh.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
---
title: "First look at loft.sh using GitOps"
description: "Installation and configuration of loft.sh with Flux"
description: "Using loft.sh for self-service Kubernetes namespaces and virtual clusters on a shared multi-tenant platform, installed and configured with Flux GitOps."
pubDate: 2022-03-15
heroImage: ../../assets/blog/loft.jpg
heroAlt: "Container in the sky"
changes: "Loft has since refocused on vCluster (loft.sh now redirects to vcluster.com), so the product, Helm chart, CLI and docs referenced here have changed. Flux's documentation has also moved to fluxcd.io/flux."
tags: [kubernetes, gitops, flux, multi-tenancy, vcluster]
service: platform-engineering
---

## The problem
Expand Down
2 changes: 1 addition & 1 deletion src/content/blog/my-2019.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "My 2019 In Review - Hello Open Source!"
description: "I look back over my 2019 and share my highlights"
description: "Looking back at 2019: 75+ pull requests to OpenFaaS and OpenFaaS Cloud, k3sup, Birmingham meetups and events, and my plans for 2020."
pubDate: 2019-12-30
heroImage: ../../assets/blog/lake.jpg
heroAlt: "A lake with sun in the background"
Expand Down
3 changes: 2 additions & 1 deletion src/content/blog/openfaas-whats-it-about.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ title: "OpenFaaS, what's it all about?"
description: "Exploring serverless on Kubernetes. To roll your own or not?"
pubDate: 2020-05-12
heroImage: ../../assets/blog/of-headline.png
heroAlt: "OpenFaaS Logo"
heroAlt: "The OpenFaaS logo"
changes: "OpenFaaS Cloud has since been retired, and k3d's commands have changed (clusters are now created with k3d cluster create). OpenFaaS itself is still actively developed; see docs.openfaas.com for current installation steps."
tags: [openfaas, serverless, kubernetes]
---

Serverless should let you focus on delivering features into production fast not fiddling with infrastructure.
Expand Down
6 changes: 4 additions & 2 deletions src/content/blog/unit-testing-helm-charts.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
---
title: "How to unit-test your helm charts with Golang"
description: "We explore unit testing Helm charts with Golang"
title: "How to Unit Test Helm Charts with Go"
description: "Write Go unit tests that render your Helm chart templates and assert on the Kubernetes manifests, so you can change charts with confidence. Includes a full example."
pubDate: 2021-01-23
heroImage: ../../assets/blog/helm.jpg
heroAlt: "A ship's wheel silhouetted against a sunset"
changes: "The networking.k8s.io/v1beta1 Ingress API used in the example was removed in Kubernetes 1.22 (use networking.k8s.io/v1), the Helm Go APIs have moved on, and the OpenFaaS Cloud repository linked below is now archived. The helm-unittest plugin is a popular alternative to the approach shown here."
tags: [helm, testing, go, kubernetes]
service: infrastructure-and-delivery
---

Learn how to write Golang unit tests for your Helm charts to keep quality high and make changes with confidence.
Expand Down
5 changes: 5 additions & 0 deletions src/data/site.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
export const site = {
name: "Heyal Solutions",
url: "https://www.heyal.co.uk",
/** Used as the full <title> on the home page. */
defaultTitle: "Kubernetes & Platform Engineering Consultancy | Heyal Solutions",
legalName: "Heyal Solutions Ltd",
description:
"Cloud native consultancy. We design, build and fix the Kubernetes platforms, infrastructure and delivery pipelines engineering teams ship on.",
email: "sales@heyal.co.uk",
companyNumber: "12189247",
registeredAddress: "Birmingham, UK",
locality: "Birmingham",
country: "GB",
founder: "Alistair Hey",
blog: "/blog/",
github: "https://github.com/Waterdrips",
Expand Down
59 changes: 50 additions & 9 deletions src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,46 @@ import "../styles/global.css";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import CallToAction from "../components/CallToAction.astro";
import { Font, getImage } from "astro:assets";
import type { ImageMetadata } from "astro";
import { site } from "../data/site";
import { graph, organization, website } from "../lib/seo";
import defaultShareImage from "../assets/og-default.jpg";

interface Props {
title?: string;
description?: string;
/** Show the closing "get in touch" band above the footer. */
cta?: boolean;
ogType?: "website" | "article";
/** Social share image, cropped to 1200x630. Falls back to the site-wide image. */
image?: ImageMetadata;
imageAlt?: string;
/** Article timestamps, rendered as article:* Open Graph tags. */
published?: Date;
modified?: Date;
/** Keep this page out of search results. */
noindex?: boolean;
/** schema.org nodes for this page. The organization and website are always included. */
schema?: object[];
}

const { title: pageTitle, description = site.description, cta = true, ogType = "website" } = Astro.props;
const title = pageTitle ? `${pageTitle} | ${site.name}` : `${site.name} | Cloud native platform consultancy`;
const {
title: pageTitle,
description = site.description,
cta = true,
ogType = "website",
image = defaultShareImage,
imageAlt = site.name,
published,
modified,
noindex = false,
schema = [],
} = Astro.props;
const title = pageTitle ? `${pageTitle} | ${site.name}` : site.defaultTitle;
const canonical = new URL(Astro.url.pathname, Astro.site);
const shareImage = await getImage({ src: image, width: 1200, height: 630, fit: "cover", format: "jpg" });
const shareImageUrl = new URL(shareImage.src, Astro.site);
---

<!doctype html>
Expand All @@ -37,19 +64,33 @@ const canonical = new URL(Astro.url.pathname, Astro.site);
</script>
<title>{title}</title>
<meta name="description" content={description} />
{noindex && <meta name="robots" content="noindex" />}
<link rel="canonical" href={canonical} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="alternate" type="application/rss+xml" title={`${site.name} blog`} href="/blog/rss.xml" />
<link rel="sitemap" href="/sitemap-index.xml" />
<meta property="og:site_name" content={site.name} />
<meta property="og:locale" content="en_GB" />
<meta property="og:type" content={ogType} />
<meta property="og:title" content={title} />
<meta property="og:title" content={pageTitle ?? title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400..800&display=swap"
/>
<meta property="og:image" content={shareImageUrl} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={imageAlt} />
{published && <meta property="article:published_time" content={published.toISOString()} />}
{modified && <meta property="article:modified_time" content={modified.toISOString()} />}
{ogType === "article" && <meta property="article:author" content={new URL("/about/", Astro.site)} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle ?? title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={shareImageUrl} />
<meta name="twitter:image:alt" content={imageAlt} />
<script is:inline type="application/ld+json" set:html={JSON.stringify(graph(organization, website, ...schema))} />
<Font cssVariable="--font-manrope" preload />
</head>
<body class="text-ink-900 selection:bg-accent-300 dark:selection:bg-accent-500/40">
<a
Expand Down
18 changes: 17 additions & 1 deletion src/lib/content.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getCollection } from "astro:content";
import { getCollection, type CollectionEntry } from "astro:content";

export async function getServices() {
const services = await getCollection("services");
Expand Down Expand Up @@ -28,6 +28,22 @@ export async function getPosts() {
);
}

/**
* Up to `limit` other technical posts, ranked by shared tags then recency. Parts of the same series are
* left out because the series navigation already links them. Personal posts get no related posts.
*/
export function relatedPosts(post: CollectionEntry<"blog">, posts: CollectionEntry<"blog">[], limit = 3) {
if (post.data.personal) return [];
const tags = new Set(post.data.tags);
const seriesId = post.data.series?.id;
return posts
.filter((p) => p.id !== post.id && !p.data.personal && (!seriesId || p.data.series?.id !== seriesId))
.map((p) => ({ p, shared: p.data.tags.filter((t) => tags.has(t)).length }))
.sort((a, b) => b.shared - a.shared || b.p.data.pubDate.valueOf() - a.p.data.pubDate.valueOf())
.slice(0, limit)
.map(({ p }) => p);
}

export function readingTime(markdown: string | undefined): number {
const words = (markdown ?? "").replace(/```[\s\S]*?```/g, "").split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 220));
Expand Down
Loading
Loading