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
Binary file modified assets/templates/blog/latest/404-dark-desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/404-dark-mobile.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/404-light-desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/404-light-mobile.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/about-dark-desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/about-dark-mobile.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/about-light-desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/about-light-mobile.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/homepage-dark-desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/homepage-dark-mobile.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/homepage-light-desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/homepage-light-mobile.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/post-dark-desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/post-dark-mobile.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/post-light-desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/post-light-mobile.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/posts-dark-desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/posts-dark-mobile.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/posts-light-desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/posts-light-mobile.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/search-dark-desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/search-dark-mobile.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/search-light-desktop.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/templates/blog/latest/search-light-mobile.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 33 additions & 26 deletions templates/blog-cloudflare/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,20 @@ This template ships with `.mcp.json`, `.cursor/mcp.json`, and `.vscode/mcp.json`

## This Template

A blog with posts, pages, categories, tags, full-text search, and RSS. Designed for personal writing, technical writing, indie newsletters, and anything where the writing is the product. Editorial-tech aesthetic: confident sans-serif, restrained accent, real article structure with bylines and reading time.
A blog with posts, pages, categories, tags, full-text search, and RSS. Designed for personal writing, technical writing, indie newsletters, and anything where the writing is the product. Neutral editorial aesthetic: a framed layout with hairline rails, white cards on an off-white canvas, Inter with JetBrains Mono labels, and real article structure with bylines and reading time.

## Pages

| Page | Path | What it shows |
| ----------- | ------------------ | ------------------------------------------------------------------------------------------------------ |
| Home | `/` | Featured post hero (large image + excerpt), latest posts grid |
| All posts | `/posts` | Article count, full post list with excerpts and tag chips |
| Post detail | `/posts/[slug]` | Featured image, title, body, left meta column (authors + date), right TOC + search + categories gutter |
| Search | `/search` | Full-text search UI |
| Page | `/pages/[slug]` | Static page content (Portable Text) |
| Category | `/category/[slug]` | Posts filtered by category |
| Tag | `/tag/[slug]` | Posts filtered by tag |
| RSS | `/rss.xml` | Generated feed |
| Page | Path | What it shows |
| ----------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| Home | `/` | Site title and tagline, featured post card (first post with an image), latest posts grid |
| All posts | `/posts` | Post rows with thumbnails, excerpts, bylines, and tag chips; cursor pagination |
| Post detail | `/posts/[slug]` | Tags, title, excerpt, featured image, then a three-column reading view: meta rail, body, table of contents and sidebar widgets |
| Search | `/search` | Full-text search over posts; matching title terms are highlighted |
| Page | `/pages/[slug]` | Static page content (Portable Text) |
| Category | `/category/[slug]` | Card grid of posts in a category |
| Tag | `/tag/[slug]` | Card grid of posts with a tag |
| RSS | `/rss.xml` | Generated feed |

## Schema

Expand All @@ -66,42 +66,49 @@ A blog with posts, pages, categories, tags, full-text search, and RSS. Designed
- Taxonomies: `category`, `tag`.
- Single `primary` menu (Home, About, Posts by default).

Site settings have `title` and `tagline` -- both render in the header / footer.
Site settings have `title` and `tagline`. The title renders in the header, the home page intro, and the footer; the tagline renders under the title in the home page intro and in the footer.

## Visual character

Single typeface: **Inter** on `--font-body`, used for everything including headings (`--font-heading` defaults to the body face; tighter letter-spacing on h1/h2). **JetBrains Mono** on `--font-mono` for inline code and code blocks. Body and headings share the same family; weight and size carry the hierarchy (`--font-weight-heading` 600, `--font-weight-display` 700 for h1/page titles).
The palette is neutral: an off-white canvas (`--color-bg`), white surfaces (`--color-surface`), and near-black ink. `--color-brand` is ink too -- it drives buttons, links, focus rings, and the site monogram. Dark mode inverts the same scale.

The brand colour is `#0066cc` (`--color-brand`) -- used for links, the post-card title hover, and the search input focus ring. There's also a secondary text colour (`--color-text-secondary`) and a `--color-muted` for meta info. Don't add a second accent.
Every page sits in a frame: two hairline rails run the full height of the page, and each full-width section (`.band`) is ruled off with a hairline that meets the rails at a small crosshair. Cards are white with a 1px ring and a soft shadow, and their images are inset so the corners stay concentric (`--radius-xl` card = `--radius-lg` image + `--card-padding`).

The article layout is the standout feature: a three-column reading view with a left meta column (author bylines, date), centred 680px body column, and a right gutter for search, table of contents, and categories. Don't flatten that into one column on desktop -- the layout signals "this is something to read".
Type is **Inter** on `--font-body` for everything, with weight and size carrying the hierarchy. **JetBrains Mono** on `--font-mono` sets code and the small uppercase labels (`.eyebrow`) used for section names and meta. Page titles use `--font-size-display` with tight tracking; the home intro pairs the site title with the tagline as a muted second line.

The article layout is the standout feature: a meta rail (authors, date, reading time), a 680px body column, and a rail with the table of contents and sidebar widgets. Below 1100px it collapses to one column, the table of contents is hidden, and the widgets move under the article. Don't flatten it on desktop -- the layout signals "this is something to read".

## Customisation

Design tokens live in `src/styles/tokens.css` with their default values. To restyle the site, override tokens in `src/styles/theme.css` -- declarations there are unlayered, so they always beat the `@layer base` defaults. Don't edit `tokens.css` or `Base.astro` for visual changes.

Colours are defined with `light-dark(<light>, <dark>)`, so each token carries both modes. Overriding with a plain colour changes light and dark at once; use `light-dark()` in the override to keep them distinct. There is no separate dark palette to maintain.
Colours are defined with `light-dark(<light>, <dark>)`, so each token carries both modes. Overriding with a plain colour changes light and dark at once; use `light-dark()` in the override to keep them distinct. There is no separate dark palette to maintain. To give the site a colour, set `--color-brand`, `--color-brand-hover`, and `--color-on-brand` together: `--color-on-brand` defaults to near-black in dark mode, to sit on the light ink brand.

Webfonts are configured in `astro.config.mjs` under `fonts:`. To swap the body face, change the `name:` for the entry bound to `cssVariable: "--font-body"`. Good alternatives: Geist, IBM Plex Sans, Söhne (if you have a licence), Public Sans. If you want a serif-bodied blog, swap to a humanist serif like Source Serif, Crimson Pro, or Lora -- but then also raise `--font-size-base` to `1.0625rem` for readability. To give headings their own face (or use a system font) without touching the font pipeline, override `--font-heading` or `--font-body` in `theme.css`.

`Base.astro` defines the shared building blocks every page uses: `.frame` (content width, aligned with the rails), `.band` and `.section` (full-width sections), `.intro`, `.eyebrow`, `.chip`, `.btn`, and `.post-grid`. Posts render through `src/components/PostCard.astro` (`card`, `feature`, and `row` variants) and bylines through `src/components/Byline.astro`.

CSS variables worth knowing (see `tokens.css` for the full list):

- `--color-brand`, `--color-brand-hover`, `--color-on-brand`, `--color-brand-ring`
- `--color-bg`, `--color-bg-subtle`, `--color-surface`, `--color-text`, `--color-text-secondary`, `--color-muted`, `--color-border`, `--color-border-subtle`
- `--color-bg`, `--color-surface`, `--color-fill`, `--color-text`, `--color-text-secondary`, `--color-muted`, `--color-border`, `--color-ring`
- `--font-body`, `--font-heading`, `--font-mono`
- `--font-weight-heading` (600) / `--font-weight-display` (700) -- heading weights; lower them if you switch to a serif
- `--tracking-tight` / `--tracking-snug` / `--tracking-wide` / `--tracking-wider` -- letter-spacing tokens used across headings and meta labels
- `--font-weight-heading` / `--font-weight-display` (both 600) -- heading weights; lower them if you switch to a serif
- `--font-size-display` -- page titles
- `--radius-sm` / `--radius` / `--radius-lg` / `--radius-xl` / `--radius-full`, `--card-padding`
- `--shadow-card`, `--shadow-card-hover`
- `--content-width` (680px) -- article body column
- `--wide-width` (1200px) -- max container
- `--gutter-width` (200px) -- right sidebar (TOC) on article pages
- `--meta-col-width` (180px) -- left meta column on article pages
- `--avatar-size-{xs,sm,md,lg}` -- byline avatar sizes at different scales
- `--wide-width` (1200px) -- the frame, rails included
- `--frame-margin` / `--frame-padding` -- space outside and inside the rails
- `--meta-col-width` (180px) / `--gutter-width` (200px) -- the article rails
- `--avatar-size-{xs,sm,md,lg}` -- byline avatar sizes

## What not to do

- Don't add a second accent colour or coloured section backgrounds. The page should be black, white, and one blue.
- Don't replace Inter with a display sans (Bebas, Anton, etc.). Headings rely on weight contrast, not novelty faces.
- Don't collapse the article gutter on desktop -- it's part of the reading experience.
- Don't add a second accent colour or coloured section backgrounds. If the site needs a colour, it goes in `--color-brand`.
- Don't replace Inter with a display sans (Bebas, Anton, etc.). Headings rely on weight and size, not novelty faces.
- Don't break the frame: new sections should be `.band` elements with their content in a `.frame`, so the rails and rules line up.
- Don't collapse the article rails on desktop -- they're part of the reading experience.
- Don't use stock blog copy ("Welcome to my blog", "Stay tuned for more"). Write a real tagline that says what this blog is about.
- Don't seed the home page with three identical placeholder posts. If you only have one real post, show one real post.
- Comments are enabled on posts and rendered on the post detail page. Configure moderation before publishing the site, or remove `commentsEnabled` and the comments UI together.
230 changes: 230 additions & 0 deletions templates/blog-cloudflare/src/components/Byline.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
---
import type { ContentBylineCredit } from "emdash";
interface Props {
bylines: ContentBylineCredit[];
/** inline: stacked avatars + names on one line. list: one row per author with role. */
layout?: "inline" | "list";
/** How many names the inline layout spells out before collapsing the rest into "+N" */
max?: number;
size?: "xs" | "sm" | "md" | "lg";
class?: string;
}
const {
bylines,
layout = "inline",
max = 1,
size = layout === "list" ? "lg" : "xs",
class: className,
} = Astro.props;
const WHITESPACE = /\s+/;
const people = bylines.map(({ byline, roleLabel }) => ({
name: byline.displayName,
role: roleLabel,
src: byline.avatarStorageKey
? (Astro.locals.emdash?.getPublicMediaUrl?.(byline.avatarStorageKey) ??
`/_emdash/api/media/file/${byline.avatarStorageKey.split("/").map(encodeURIComponent).join("/")}`)
: null,
initials: byline.displayName
.split(WHITESPACE)
.filter(Boolean)
.slice(0, layout === "list" ? 2 : 1)
.map((word) => [...word][0]?.toUpperCase() ?? "")
.join(""),
}));
const shown = people.slice(0, max).map((person) => person.name);
const hidden = people.slice(max).map((person) => person.name);
---

{
people.length > 0 && layout === "list" && (
<ul class:list={["byline-list", `byline--${size}`, className]}>
{people.map((person) => (
<li class="byline-list__item">
{person.src ? (
<img
src={person.src}
alt=""
class="byline__avatar"
loading="lazy"
decoding="async"
/>
) : (
<span
class="byline__avatar byline__avatar--initials"
aria-hidden="true"
>
{person.initials}
</span>
)}
<span class="byline-list__text">
<span class="byline-list__name">{person.name}</span>
{person.role && (
<span class="byline-list__role">{person.role}</span>
)}
</span>
</li>
))}
</ul>
)
}
{
people.length > 0 && layout === "inline" && (
<span class:list={["byline", `byline--${size}`, className]}>
<span class="byline__avatars" aria-hidden="true">
{people
.slice(0, 3)
.map((person) =>
person.src ? (
<img
src={person.src}
alt=""
class="byline__avatar"
loading="lazy"
decoding="async"
/>
) : (
<span class="byline__avatar byline__avatar--initials">
{person.initials}
</span>
),
)}
</span>
<span class="byline__names">
{shown.map((name, index) => (
<>
{index > 0 &&
(index === shown.length - 1 && hidden.length === 0
? " and "
: ", ")}
{name}
</>
))}
{hidden.length > 0 && (
<>
<span
class="byline__more"
aria-hidden="true"
title={hidden.join(", ")}
>
+{hidden.length}
</span>
<span class="sr-only">and {hidden.join(", ")}</span>
</>
)}
</span>
</span>
)
}

<style>
.byline,
.byline-list {
--avatar-size: var(--avatar-size-xs);
}

.byline--sm {
--avatar-size: var(--avatar-size-sm);
}

.byline--md {
--avatar-size: var(--avatar-size-md);
gap: var(--spacing-3);
color: var(--color-text);
}

.byline--lg {
--avatar-size: var(--avatar-size-lg);
}

.byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-secondary);
}

.byline__avatars {
display: inline-flex;
flex-shrink: 0;
}

.byline__avatar {
flex-shrink: 0;
width: var(--avatar-size);
height: var(--avatar-size);
border-radius: var(--radius-full);
object-fit: cover;
background: var(--color-fill);
box-shadow: 0 0 0 2px var(--avatar-ring, var(--color-surface));
outline: 1px solid var(--color-media-outline);
outline-offset: -1px;
}

.byline__avatar + .byline__avatar {
margin-inline-start: calc(var(--avatar-size) * -0.3);
}

.byline__avatar--initials {
display: grid;
place-items: center;
font-size: calc(var(--avatar-size) * 0.38);
font-weight: 600;
letter-spacing: 0;
line-height: 1;
color: var(--color-text-secondary);
}

.byline__names {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.byline__more {
margin-inline-start: var(--spacing-1);
color: var(--color-muted);
}

.byline-list {
display: grid;
gap: var(--spacing-3);
list-style: none;
padding: 0;
}

.byline-list__item {
display: flex;
align-items: center;
gap: var(--spacing-3);
min-width: 0;
}

.byline-list .byline__avatar {
box-shadow: none;
}

.byline-list__text {
display: grid;
min-width: 0;
}

.byline-list__name {
font-size: var(--font-size-sm);
font-weight: 500;
line-height: var(--leading-snug);
color: var(--color-text);
}

.byline-list__role {
font-size: var(--font-size-xs);
line-height: var(--leading-snug);
color: var(--color-muted);
}
</style>
71 changes: 71 additions & 0 deletions templates/blog-cloudflare/src/components/Pagination.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
interface Props {
/** Path of the first page, e.g. "/posts" */
basePath: string;
cursor?: string;
nextCursor?: string;
}

const { basePath, cursor, nextCursor } = Astro.props;
---

{
(cursor || nextCursor) && (
<nav class="pagination" aria-label="Pagination">
{cursor && (
<a href={basePath} class="btn btn--ghost">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M19 12H5" />
<path d="m12 19-7-7 7-7" />
</svg>
First page
</a>
)}
{nextCursor && (
<a
href={`${basePath}?cursor=${encodeURIComponent(nextCursor)}`}
class="btn btn--ghost pagination__next"
>
Next page
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</a>
)}
</nav>
)
}

<style>
.pagination {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-3);
margin-top: var(--spacing-10);
}

.pagination__next {
margin-inline-start: auto;
}

.pagination svg:dir(rtl) {
scale: -1 1;
}
</style>
Loading
Loading