Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

Marketing site for Focus, built with Astro and Tailwind CSS v4. The current site is Astro-first, with React available when a page genuinely needs client-side state or interactivity.

## Publishing Blog Posts

Blog posts live at `src/blog/<slug>/index.mdx`. Put images used by a post in the same folder, import them into the MDX file, and render them with Astro's `Image` component so the build can optimize them.

A post is included in production only when `draft` is `false`. Publishing is therefore a normal pull request: add or edit
the content, get it reviewed, and merge it to `main`.

## Stack

- Astro 6
Expand All @@ -27,10 +34,13 @@ All commands run from the repo root:
/
├── public/
├── src/
│ ├── blog/
│ ├── components/
│ │ └── page-sections/
│ │ └── shared/
│ ├── data/
│ ├── layouts/
│ ├── lib/
│ ├── pages/
│ │ └── index.astro
│ └── styles/
Expand Down
6 changes: 4 additions & 2 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import tailwindcss from "@tailwindcss/vite";

import react from "@astrojs/react";

import mdx from "@astrojs/mdx";

import sitemap from "@astrojs/sitemap";

// https://astro.build/config
Expand All @@ -12,9 +14,9 @@ export default defineConfig({
plugins: [tailwindcss()],
},
site: "https://focusconsulting.io",
integrations: [react(), sitemap()],
integrations: [react(), mdx(), sitemap()],
redirects: {
'/about': '/about-focus',
'/services': '/capabilities',
},
});
});
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^5.0.6",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we pin these new deps?

"@astrojs/react": "5.0.2",
"@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.2",
"@tailwindcss/vite": "4.2.2",
"@types/react": "19.2.14",
Expand All @@ -21,6 +23,7 @@
"astro-seo": "^1.1.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"sharp": "^0.34.5",
"tailwindcss": "4.2.2"
},
"pnpm": {
Expand Down
665 changes: 655 additions & 10 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions src/blog/building-services-that-endure/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
title: "Building services that endure"
description: "A practical way to make early delivery, policy learning, and long-term stewardship reinforce one another."
pubDate: 2026-06-18
author: Bryan Bassett
tags:
- delivery
- public services
draft: true
---

import { Image } from "astro:assets";
import serviceMap from "./service-map.png";

The first release of a public service is not the finish line. It is the first
reliable opportunity to learn how policy, operations, and technology behave
together in the real world.

<Image
src={serviceMap}
alt="Focus team members collaborating around a table"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume we're not going to publish this one, but I think the alt is wrong?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, we're not going to publish this, but I'll update the alt text here.

widths={[640, 960, 1280]}
sizes="(max-width: 720px) 100vw, 720px"
/>

## Start with a complete path

A small release should still let someone complete a meaningful task. That may
mean serving one program, one user group, or one carefully chosen scenario
before broadening the service. The boundary is narrow; the outcome is whole.

This approach gives a team evidence that planning alone cannot provide:

- where people hesitate or abandon the process;
- which policy questions need an operational answer;
- what staff need in order to support the service; and
- which technical choices will be expensive to reverse.

## Treat learning as delivery

The goal of an early release is not simply to ship sooner. It is to shorten the
distance between a decision and the evidence needed to improve it. Teams can
then make smaller, clearer decisions while change is still affordable.

Enduring services grow from that rhythm: deliver a useful slice, observe the
whole system, and make the next commitment with better information.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/components/shared/Eyebrow.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div class="mb-4 text-sm font-semibold uppercase text-[var(--color-brand-accent)]">
<slot />
</div>
8 changes: 3 additions & 5 deletions src/components/shared/PageHero.astro
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
---
import Eyebrow from './Eyebrow.astro';

interface Props {
eyebrow?: string;
headline: string;
Expand Down Expand Up @@ -47,11 +49,7 @@ const { eyebrow, headline, description, fullWidth = false, ctaText, ctaHref, cta

<div class="page-shell relative z-1">
<div class="text-left">
{eyebrow && (
<div class="mb-4 text-sm font-semibold uppercase text-[var(--color-brand-accent)]">
{eyebrow}
</div>
)}
{eyebrow && (<Eyebrow>{eyebrow}</Eyebrow>)}

<h1
class="display-2"
Expand Down
21 changes: 21 additions & 0 deletions src/content.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { defineCollection, reference } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";

const blog = defineCollection({
loader: glob({
base: "./src/blog",
pattern: "**/index.mdx",
generateId: ({ entry }) => entry.replace(/\/index\.mdx$/, ""),
}),
schema: z.object({
title: z.string().min(1),
description: z.string().min(1),
pubDate: z.coerce.date(),
author: z.string().min(1),
tags: z.array(z.string().min(1)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks unused?

draft: z.coerce.boolean().default(false),
}),
});

export const collections = { blog };
19 changes: 14 additions & 5 deletions src/layouts/Footer.astro
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
---
import { getBlogPostsSortedByPubDate } from "../lib/blog";

let exploreGroupListItems = [
{ label: "Home", href: "/" },
{ label: "Capabilities", href: "/capabilities" },
{ label: "Our Work", href: "/our-work" },
];

const posts = await getBlogPostsSortedByPubDate();
if (posts && posts.length > 0) {
exploreGroupListItems.push({ label: "Blog", href: "/blog" });
}

const footerGroups = [
{
label: "Explore",
items: [
{ label: "Home", href: "/" },
{ label: "Capabilities", href: "/capabilities" },
{ label: "Our Work", href: "/our-work" },
],
items: exploreGroupListItems,
},
{
label: "About Focus",
Expand Down
16 changes: 16 additions & 0 deletions src/lib/blog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { getCollection } from "astro:content";

export async function getBlogPostsSortedByPubDate() {
return (await getCollection('blog')).filter(
(x) => !x.data.draft,
).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
}

export function formatPostDate(date: Date) {
return new Intl.DateTimeFormat("en-US", {
dateStyle: "long",
timeZone: "UTC",
}).format(date);
}
56 changes: 56 additions & 0 deletions src/pages/blog/[slug].astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
import { render } from "astro:content";
import Eyebrow from "../../components/shared/Eyebrow.astro";
import Layout from "../../layouts/Layout.astro";
import {
formatPostDate,
getBlogPostsSortedByPubDate,
} from "../../lib/blog";

export async function getStaticPaths() {
return (await getBlogPostsSortedByPubDate()).map((post) => ({
params: { slug: post.id },
props: { post },
}));
}

const { post } = Astro.props;
const { Content } = await render(post);
---

<Layout
title={post.data.title}
description={post.data.description}
ogType="article"
>
<article class="page-shell pb-20 pt-20 sm:pb-28">
<header class="layout-grid border-b border-[var(--color-brand-border)] pb-6">
<div class="col-span-12">
<Eyebrow><a href="/blog">Blog</a></Eyebrow>
<h1 class="display-2 mt-5">{post.data.title}</h1>
</div>
<div class="col-span-12 lg:col-span-8">
<p class="body-copy text-[1.2rem]">{post.data.description}</p>
<div class="mt-6 flex flex-wrap gap-x-3 text-[0.95rem] text-[var(--color-brand-soft)]">
<time dateTime={post.data.pubDate}>{formatPostDate(post.data.pubDate)}</time>
<span aria-hidden="true">•</span>
<span>{post.data.author}</span>
</div>
</div>
</header>

<div class="layout-grid pt-10">
<div class="blog-prose col-span-12 lg:col-span-8">
<Content />
</div>
</div>

<footer class="layout-grid mt-14 border-t border-[var(--color-brand-border)] pt-8">
<div class="col-span-12 lg:col-span-8">
<a class="inline-flex font-medium text-[var(--color-brand-accent)] underline underline-offset-4" href="/blog/">
Back to all blog posts
</a>
</div>
</footer>
</article>
</Layout>
50 changes: 50 additions & 0 deletions src/pages/blog/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
import Layout from "../../layouts/Layout.astro";
import Eyebrow from "../../components/shared/Eyebrow.astro";
import {
formatPostDate,
getBlogPostsSortedByPubDate,
} from "../../lib/blog";

const posts = await getBlogPostsSortedByPubDate();
---

<Layout
title="Blog"
description="Ideas and field notes from Focus about public digital services, delivery, and building systems that endure."
>
<section class="page-shell pt-6 pb-12 sm:pt-20 sm:pb-18">
<Eyebrow>Blog</Eyebrow>

<div>
<ol>
{posts.map((post) => (
<li key={post.id} class="border-b border-[var(--color-brand-border)]">
<article class="grid gap-7 py-10 md:grid-cols-[minmax(0,1fr)_13rem] md:items-start md:py-12">
<div>
<h2 class="section-heading">
<a
class="no-underline hover:text-[var(--color-brand-accent)]"
href={`/blog/${post.id}/`}
>
{post.data.title}
</a>
</h2>
<p class="body-copy mt-4">{post.data.description}</p>
<div class="mt-4 flex flex-wrap gap-x-3 text-[0.86rem] text-[var(--color-brand-soft)]">
<time dateTime={post.data.pubDate}>{formatPostDate(post.data.pubDate)}</time>
<span aria-hidden="true">•</span>
<span>{post.data.author}</span>
</div>
</div>
</article>
</li>
))}
</ol>
</div>

<a class="mt-8 inline-flex text-[0.92rem] font-medium text-[var(--color-brand-accent)] underline underline-offset-4" href="/rss.xml">
Subscribe via RSS
</a>
</section>
</Layout>
21 changes: 21 additions & 0 deletions src/pages/rss.xml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import rss from "@astrojs/rss";
import { getBlogPostsSortedByPubDate } from "../lib/blog";

export async function GET(context: { site: URL }) {
const posts = await getBlogPostsSortedByPubDate();

return rss({
title: "Focus blog",
description:
"Ideas and field notes from Focus about public digital services, delivery, and building systems that endure.",
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
pubDate: post.data.pubDate,
publishDate: post.data.publishDate,

link: `/blog/${post.id}/`,
categories: post.data.tags,
author: post.data.author,
})),
});
}
Loading