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
6 changes: 6 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "site", "dev", "--port", "4388", "--strictPort"],
"port": 4388
},
{
"name": "site-dist (static 4389)",
"runtimeExecutable": "python3",
"runtimeArgs": ["-m", "http.server", "4389", "-d", "site/dist"],
"port": 4389
}
]
}
462 changes: 462 additions & 0 deletions docs/explorations/0269_[x]_BLOG_POST_AUTHOR_BYLINES_WITH_AVATARS.md

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions site/public/blog/authors/claude.svg
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 site/public/blog/authors/crs48.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
88 changes: 88 additions & 0 deletions site/src/components/blog/Byline.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
// Author byline for blog posts (exploration 0269): avatar stack + names, with
// AI co-authors introduced by "with" rather than listed as undifferentiated
// peers. Avatars are first-party assets under site/public/blog/authors/ —
// several essays promise "this page loads nothing third-party", so this
// component must never hotlink an avatar host.
//
// Two variants:
// - full (default): linked names + an Article JSON-LD block, for post pages.
// - compact: smaller, link-free (index cards are wrapped in an <a>, and
// nested anchors are invalid HTML), no JSON-LD.
import { postAuthors, type BlogPost } from '../../data/blog'
import { postUrl } from '../../lib/blog-feed'

interface Props {
post: BlogPost
compact?: boolean
}
const { post, compact = false } = Astro.props
const authors = postAuthors(post)

// schema.org Article with the full author list. An AI is arguably not a
// schema.org Person, but Google's article guidance only recognises
// Person/Organization for `author`; the clarifying `url` carries the nuance.
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.description,
datePublished: post.pubDate,
url: postUrl(post),
author: authors.map((a) => ({
'@type': 'Person',
name: a.name,
...(a.href ? { url: a.href } : {})
}))
}

const avatarSize = compact ? 18 : 24
---

<div
class:list={[
'not-prose flex flex-wrap items-center',
compact ? 'gap-1.5 text-xs' : 'gap-2 text-sm text-gray-500 dark:text-gray-400'
]}
>
<span class:list={['flex', compact ? '-space-x-1.5' : '-space-x-2']}>
{
authors.map((a) => (
<img
src={a.avatar}
alt={a.name}
width={avatarSize}
height={avatarSize}
loading="lazy"
class:list={[
'rounded-full',
compact
? 'ring-1 ring-white dark:ring-gray-900'
: 'ring-2 ring-white dark:ring-gray-900'
]}
/>
))
}
</span>
{
authors.map((a, i) => (
<>
{i > 0 && <span aria-hidden="true">{a.ai ? 'with' : '·'}</span>}
{compact || !a.href ? (
<span class="font-medium">{a.name}</span>
) : (
<a
href={a.href}
target="_blank"
rel="noopener noreferrer"
class="font-medium text-gray-600 transition-colors hover:text-indigo-500 dark:text-gray-300 dark:hover:text-indigo-400"
>
{a.name}
</a>
)}
</>
))
}
</div>

{!compact && <script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />}
64 changes: 52 additions & 12 deletions site/src/data/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,41 @@
| 'cosmos'
| 'economics'

/** Registered blog authors; posts reference these by id (exploration 0269). */
export type BlogAuthorId = 'crs48' | 'claude'

export interface BlogAuthor {
id: BlogAuthorId
/** Display name shown in the byline. */
name: string
/** Profile link (GitHub for humans; product page for AI agents). */
href?: string
/**
* First-party avatar path under `site/public/` — never a hotlink. Several
* essays promise "this page loads nothing third-party", so avatars are
* vendored assets served from the site's own origin.
*/
avatar: string
/** Marks an AI co-author; drives the "with …" byline treatment. */
ai?: boolean
}

export const AUTHORS: Record<BlogAuthorId, BlogAuthor> = {
crs48: {
id: 'crs48',
name: 'crs48',
href: 'https://github.com/crs48',
avatar: '/blog/authors/crs48.jpg'
},
claude: {
id: 'claude',
name: 'Claude',
href: 'https://claude.com/claude-code',
avatar: '/blog/authors/claude.svg',
ai: true
}
}

export interface BlogPost {
/** URL slug; matches `site/src/pages/blog/<slug>.astro`. */
slug: string
Expand All @@ -39,8 +74,8 @@
description: string
/** ISO-8601 UTC instant the post was published. */
pubDate: string
/** Display author. */
author: string
/** Authors shown in the byline, humans first. */
authors: BlogAuthorId[]
tags: BlogTag[]
/** Rough read time in minutes, shown on the card. */
readingMinutes: number
Expand All @@ -63,7 +98,7 @@
'code, and what software looks like when the application is just a view ' +
'over data you own — especially now that anyone can cook.',
pubDate: '2026-07-05T23:00:00Z',
author: 'xNet',
authors: ['crs48', 'claude'],
tags: ['essay', 'philosophy', 'decentralization'],
readingMinutes: 14
},
Expand All @@ -79,7 +114,7 @@
'takes to actually hold a course — and the small, real instruments a piece of ' +
'software can hand back.',
pubDate: '2026-07-03T15:00:00Z',
author: 'xNet',
authors: ['crs48', 'claude'],
tags: ['essay', 'philosophy', 'decentralization'],
readingMinutes: 15
},
Expand All @@ -89,7 +124,7 @@
description:
"You write useQuery(TaskSchema) and get a live, local, cryptographically-authorised, syncing database — with no API endpoint, no auth middleware, and no cache to invalidate. A developer's tour of xNet's React hooks on the surface, then a dive beneath the waterline to the SQLite database running in a worker, the priority scheduler, and the signed change log that make “just trust the client” safe. The tip is small on purpose; the iceberg is yours to open.",
pubDate: '2026-06-29T17:30:00Z',
author: 'xNet',
authors: ['crs48', 'claude'],
tags: ['essay', 'protocol', 'decentralization'],
readingMinutes: 14
},
Expand All @@ -99,7 +134,7 @@
description:
"The Luddites didn't fear machines — they refused looms they weren't allowed to open. Follow one note, “Buy milk,” all the way through xNet's internals: a file on your own disk, a signed change log, a name you mint instead of an account, and a three-line merge that settles conflicts with no server in the middle. A guided tour of a machine you're allowed to open — written for developers and everyone else at once.",
pubDate: '2026-06-29T01:09:07Z',
author: 'xNet',
authors: ['crs48', 'claude'],
tags: ['essay', 'protocol', 'decentralization'],
readingMinutes: 15
},
Expand All @@ -109,7 +144,7 @@
description:
'Industrial farming strips the soil to exhaustion and trucks fertility back in by the ton. Surveillance capitalism does the same to the web. Permaculture is the discipline for growing land that feeds itself — and its principles are, almost furrow for furrow, how you regenerate a digital commons instead of strip-mining one.',
pubDate: '2026-06-28T23:39:38Z',
author: 'xNet',
authors: ['crs48', 'claude'],
tags: ['essay', 'philosophy', 'nature'],
readingMinutes: 14
},
Expand All @@ -119,7 +154,7 @@
description:
"A musician on YouTube argues the economy quietly changed from growth to extraction, and the real prize isn't your money — it's your ability to refuse. He's mostly right. Here's the part software can actually give back.",
pubDate: '2026-06-28T22:10:50Z',
author: 'xNet',
authors: ['crs48', 'claude'],
tags: ['essay', 'philosophy', 'economics'],
readingMinutes: 13
},
Expand All @@ -129,7 +164,7 @@
description:
'Every year a dead desert blows across an ocean and feeds the most alive place on Earth — replacing almost exactly what the rainforest loses. What Saharan dust, the bees nobody watches, and the maintainers nobody thanks teach us about the invisible substrate the open web runs on.',
pubDate: '2026-06-28T21:46:46Z',
author: 'xNet',
authors: ['crs48', 'claude'],
tags: ['essay', 'philosophy', 'nature'],
readingMinutes: 13
},
Expand All @@ -139,7 +174,7 @@
description:
'A star carries the energy of a billion bombs and still feels calm from here. What hydrostatic equilibrium — the thermostat that keeps a star from exploding or going cold — teaches us about information, attention, and building technology that burns long instead of burning out.',
pubDate: '2026-06-28T02:27:04Z',
author: 'xNet',
authors: ['crs48', 'claude'],
tags: ['essay', 'philosophy', 'cosmos'],
readingMinutes: 13
},
Expand All @@ -149,7 +184,7 @@
description:
'Beneath every forest runs a fungal network — the original internet. What mycelium, the human nervous system, and Tesla’s Warp teach us about building one worth living in, and how to heal one that’s gone sick.',
pubDate: '2026-06-28T01:23:39Z',
author: 'xNet',
authors: ['crs48', 'claude'],
tags: ['essay', 'philosophy', 'nature'],
readingMinutes: 12
},
Expand All @@ -159,7 +194,7 @@
description:
'What pirates — the real ones, and the ones in One Piece — can teach us about owning your data. An essay on freedom, self-governance, and why you are the cargo.',
pubDate: '2026-06-28T00:28:34Z',
author: 'xNet',
authors: ['crs48', 'claude'],
tags: ['essay', 'philosophy', 'decentralization'],
readingMinutes: 11
}
Expand Down Expand Up @@ -201,6 +236,11 @@
return posts.find((p) => p.slug === slug)
}

/** Resolve a post's author ids to full registry entries, in declared order. */
export function postAuthors(post: BlogPost): BlogAuthor[] {
return post.authors.map((id) => AUTHORS[id])
}

/** Human-friendly date, e.g. "June 27, 2026", rendered from the ISO instant. */
export function formatPostDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"id": "2026-07-05-blog-posts-now-credit-their-authors",
"date": "July 5, 2026",
"title": "Blog posts now credit their authors",
"summary": "Every essay on the blog carries a byline — the human author linked to their GitHub profile, and Claude credited as the AI co-author — with first-party avatar images, per-author RSS credits, and structured data for search engines.",
"highlights": [],
"tags": ["platform"]
}
11 changes: 8 additions & 3 deletions site/src/lib/blog-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* wraps in a Response, so the feed and the page render from the same data module.
*/

import type { BlogPost } from '../data/blog'
import { postAuthors, type BlogPost } from '../data/blog'

export const SITE_URL = 'https://xnet.fyi'
export const BLOG_URL = `${SITE_URL}/blog`
Expand Down Expand Up @@ -33,13 +33,18 @@ export function buildBlogRss(posts: BlogPost[]): string {
const categories = post.tags
.map((t) => ` <category>${escapeXml(t)}</category>`)
.join('\n')
// RSS 2.0's <author> is an email address, so names go in Dublin Core's
// <dc:creator> — one element per author, in byline order.
const creators = postAuthors(post)
.map((a) => ` <dc:creator>${escapeXml(a.name)}</dc:creator>`)
.join('\n')
return [
' <item>',
` <title>${escapeXml(post.title)}</title>`,
` <link>${escapeXml(url)}</link>`,
` <guid isPermaLink="true">${escapeXml(url)}</guid>`,
` <description>${escapeXml(post.description)}</description>`,
` <author>${escapeXml(post.author)}</author>`,
creators,
` <pubDate>${pubDate}</pubDate>`,
categories,
' </item>'
Expand All @@ -53,7 +58,7 @@ export function buildBlogRss(posts: BlogPost[]): string {
posts.length > 0 ? new Date(posts[0].pubDate).toUTCString() : new Date(0).toUTCString()

return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>xNet Blog</title>
<link>${BLOG_URL}</link>
Expand Down
3 changes: 3 additions & 0 deletions site/src/pages/blog/a-great-pirate-age.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Nav from '../../components/sections/Nav.astro'
import Footer from '../../components/sections/Footer.astro'
import SeriesNav from '../../components/blog/SeriesNav.astro'
import PirateHero from '../../components/blog/PirateHero.astro'
import Byline from '../../components/blog/Byline.astro'
import ArticlesMap from '../../components/blog/ArticlesMap.astro'
import HonestPirate from '../../components/blog/HonestPirate.astro'
import { postBySlug, formatPostDate } from '../../data/blog'
Expand All @@ -26,6 +27,8 @@ const post = postBySlug('a-great-pirate-age')!
<article
class="prose prose-lg mx-auto max-w-3xl px-6 py-16 dark:prose-invert prose-headings:tracking-tight prose-a:text-indigo-600 dark:prose-a:text-indigo-400"
>
<Byline post={post} />

<p>
The most famous age of pirates began, in the story, with an execution. The
World Government put the Pirate King to death in the public square to make
Expand Down
3 changes: 3 additions & 0 deletions site/src/pages/blog/data-should-work-like-soil.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Nav from '../../components/sections/Nav.astro'
import Footer from '../../components/sections/Footer.astro'
import SeriesNav from '../../components/blog/SeriesNav.astro'
import MycelialHero from '../../components/blog/MycelialHero.astro'
import Byline from '../../components/blog/Byline.astro'
import ThreeNervousSystems from '../../components/blog/ThreeNervousSystems.astro'
import HonestMycelium from '../../components/blog/HonestMycelium.astro'
import { postBySlug, formatPostDate } from '../../data/blog'
Expand All @@ -26,6 +27,8 @@ const post = postBySlug('data-should-work-like-soil')!
<article
class="prose prose-lg mx-auto max-w-3xl px-6 py-16 dark:prose-invert prose-headings:tracking-tight prose-a:text-emerald-600 dark:prose-a:text-emerald-400"
>
<Byline post={post} />

<p>
Last time, we looked up — at an open sea of scattered islands, and the
flags a person might fly to stop being someone else’s cargo. This time,
Expand Down
3 changes: 3 additions & 0 deletions site/src/pages/blog/hand-on-the-tiller.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Nav from '../../components/sections/Nav.astro'
import Footer from '../../components/sections/Footer.astro'
import SeriesNav from '../../components/blog/SeriesNav.astro'
import TillerHero from '../../components/blog/TillerHero.astro'
import Byline from '../../components/blog/Byline.astro'
import AlignmentStack from '../../components/blog/AlignmentStack.astro'
import HonestTiller from '../../components/blog/HonestTiller.astro'
import { postBySlug, formatPostDate } from '../../data/blog'
Expand All @@ -26,6 +27,8 @@ const post = postBySlug('hand-on-the-tiller')!
<article
class="prose prose-lg mx-auto max-w-3xl px-6 py-16 dark:prose-invert prose-headings:tracking-tight prose-a:text-sky-600 dark:prose-a:text-sky-400"
>
<Byline post={post} />

<p>
Watch a good helmsman for a minute and you notice they never really stop
moving. The wheel is always drifting a few degrees one way while the sea,
Expand Down
4 changes: 4 additions & 0 deletions site/src/pages/blog/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import LoomArt from '../../components/blog/LoomArt.astro'
import HookArt from '../../components/blog/HookArt.astro'
import TillerArt from '../../components/blog/TillerArt.astro'
import WorkshopArt from '../../components/blog/WorkshopArt.astro'
import Byline from '../../components/blog/Byline.astro'
import { publishedPosts, formatPostDate } from '../../data/blog'

const posts = publishedPosts()
Expand Down Expand Up @@ -97,6 +98,9 @@ const heroArt: Record<string, typeof PirateArt> = {
<p class="mt-2 text-gray-600 dark:text-gray-300 leading-relaxed">
{post.description}
</p>
<div class="mt-4 text-gray-500 dark:text-gray-400">
<Byline post={post} compact />
</div>
<span class="mt-4 inline-flex items-center gap-1 text-sm font-medium text-indigo-600 dark:text-indigo-400">
Read
<svg
Expand Down
Loading
Loading