<?xml version="1.0" encoding="utf-8"?>
  <rss version="2.0"
    xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:wfw="http://wellformedweb.org/CommentAPI/"
    xmlns:dc="http://purl.org/dc/elements/1.1/"
    xmlns:atom="http://www.w3.org/2005/Atom"
    xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
    xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
    xmlns:georss="http://www.georss.org/georss"
    xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#"
  >
    <channel>
      <title>Piccalilli - Everything</title>
      <link>https://piccalil.li/</link>
      <atom:link href="https://piccalil.li/feed.xml" rel="self" type="application/rss+xml" />
      <description>We are Piccalilli. A publication dedicated to providing high quality educational content to level up your front-end skills.</description>
      <language>en-GB</language>
      <copyright>Piccalilli - Everything 2026</copyright>
      <docs>https://www.rssboard.org/rss-specification</docs>
      <pubDate>Thu, 09 Jul 2026 03:05:08 GMT</pubDate>
      <lastBuildDate>Thu, 09 Jul 2026 03:05:08 GMT</lastBuildDate>

      
      <item>
        <title>The Index: Issue #189</title>
        <link>https://piccalil.li/the-index/189/?ref=main-rss-feed</link>
        <dc:creator><![CDATA[Andy Bell]]></dc:creator>
        <pubDate>Fri, 03 Jul 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/the-index/189/?ref=main-rss-feed</guid>
        <description><![CDATA[<h2><a href="https://dbushell.com/2026/07/03/fixing-full-bleed-css/?utm_source=the-index&amp;utm_medium=newsletter">Fixing full-bleed CSS</a></h2>
<p>A rather deep dive into how some of the newer CSS can make this age old pattern even better.</p>
<h2><a href="https://rachelandrew.co.uk/archives/2026/06/11/wheres-the-holistic-ai-productivity-data/?utm_source=the-index&amp;utm_medium=newsletter">Where’s the holistic AI productivity data?</a></h2>
<blockquote>
<p>It’s hard to find anything other than anecdata from individuals telling us how AI has made them individually more productive. If AI really was creating measurable improvements in productivity across entire organisations, wouldn’t we be seeing that data?</p>
</blockquote>
<p><em>[nods]</em></p>
<h2><a href="https://jakearchibald.com/2026/goldilocks-select-height/?utm_source=the-index&amp;utm_medium=newsletter">The Goldilocks customizable select height</a></h2>
<p>Even the new custom <code>&lt;select&gt;</code> capabilities bring headaches, but at least Jake's got height sorted for you.</p>
<h2><a href="https://bayeuxgo.live/?utm_source=the-index&amp;utm_medium=newsletter">Bayeux Go!</a></h2>
<p>This is great fun!</p>
<h2><a href="https://ditherimage.online/?utm_source=the-index&amp;utm_medium=newsletter">Dithering tool</a></h2>
<p>A very handy looking tool that certainly beats a laborious Photoshop process.</p>
<h2><a href="https://piccalil.li/blog/a-guide-to-destructuring-in-javascript/?utm_source=the-index&amp;utm_medium=newsletter">A guide to destructuring in JavaScript</a></h2>
<p>Here's one from the <a href="https://piccalil.li/blog/">Piccalilli archives</a> that you might have missed to wrap up this issue.</p>
<hr />
<p>P.S. <a href="https://sallylait.com/?utm_source=the-index&amp;utm_medium=newsletter">this is a good website</a> from <a href="https://personalsit.es/?utm_source=the-index&amp;utm_medium=newsletter">personalsit.es</a>.</p>
        
        <h2>Sponsor message</h2><a href="https://courses.oddbird.net/poetic-css/?utm_source=the-index&utm_medium=newsletter"><img src="https://piccalil.b-cdn.net/images/ads/poetic-css.jpg" alt="Poetic CSS" /></a><p><strong>Poetic CSS is a new video and text course. Early bird pricing available July 2026.</strong></p>
<p>Understand CSS as a system with Miriam Suzanne — web developer, teacher, and pioneer of modern CSS — so you can write, review, and maintain web styles with confidence on a timeline and under budget.</p>
<p><a href="https://courses.oddbird.net/poetic-css/?utm_source=the-index&utm_medium=newsletter">Register now</a></p>]]></description>
        
      </item>
    
      <item>
        <title>Personal website redesign project post: Rendering AT protocol posts on my /feed</title>
        <link>https://piccalil.li/projects/personal-site/7/?ref=main-rss-feed</link>
        <dc:creator><![CDATA[Andy Bell]]></dc:creator>
        <pubDate>Thu, 02 Jul 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/projects/personal-site/7/?ref=main-rss-feed</guid>
        <description><![CDATA[<p>I'm again, going to be doing a lot of the same sort of work I did for the WordPress integration, but as this is the first iteration of the AT protocol, it's going to be a lot simpler than that.</p>
<p>Just like with the WordPress integration, I used Astro's pagination capabilities to build a paginated "feed" of posts, to satisfy the "Basic rendering of my AT protocol posts" part of the <a href="https://piccalil.li/projects/personal-site/2/#work-out-the-core-features">core features I outlined at the beginning of this series</a>.</p>
<p><img src="https://piccalil.b-cdn.net/images/projects/personal-site-core-features.jpg" alt="An Obsidian markdown file called &quot;core features and iterations.&quot; It lists a development roadmap across four iterations, including tasks like &quot;basic shell version of the site,&quot; &quot;look and feel design,&quot; &quot;AT protocol integration,&quot; and &quot;last.fm integration.&quot;" /></p>
<p>Let's get stuck into that bit first. I created a <code>pages/feed/[...page].astro</code> file and filled it with the following:</p>
<pre><code>---
import { fetchAllATPosts } from '@repo/data/atPosts';

import PageLayout from 'src/layouts/PageLayout.astro';
import Pagination from '@repo/ui/Pagination';
import ATPostsRegion from '@repo/ui/ATPostsRegion';

export async function getStaticPaths({ paginate }) {
  const posts = await fetchAllATPosts();
  return paginate(posts, { pageSize: 20 });
}

const { page } = Astro.props;
---

&lt;PageLayout title="Feed" summary="" socialImage="" allowRobots={true}&gt;
  &lt;ATPostsRegion posts={page.data} /&gt;
  &lt;Pagination previous={page.url.prev} next={page.url.next} /&gt;
&lt;/PageLayout&gt;
</code></pre>
<p>I'm getting all the posts with the functionality written in the <a href="https://piccalil.li/projects/personal-site/6">last article</a>, limiting them to 20 per page and then instructing Astro what pages need building with <code>getStaticPaths()</code>.</p>
<p>From there and for each page, I've got a <code>page</code> prop, which just like with the WordPress integration, I'm feeding to the <code>&lt;Pagination&gt;</code> component. I'm also feeding that data to the <code>&lt;ATPostsRegion&gt;</code> which I'll break down next.</p>
<p></p>
<h2>The AT posts region</h2>
<p>I didn't break down the <code>&lt;PostsRegion&gt;</code> in the WordPress section of this series because it is literally just a list of links, but this one is <em>slightly</em> different, but albeit <em>simple</em>. We are in the early days of this rebuild after all!</p>
<pre><code>---
const { posts } = Astro.props;

import ATPost from '@repo/ui/ATPost';
---

&lt;div class="at-posts-region region"&gt;
  &lt;h1 class="visually-hidden"&gt;Feed&lt;/h1&gt;
  &lt;div class="wrapper flow"&gt;
    &lt;p&gt;ℹ️ Posts from my &lt;a href="https://bsky.app/profile/bell.bz"&gt;Bluesky profile&lt;/a&gt;.&lt;/p&gt;
    &lt;ul class="at-posts-region__list" role="list"&gt;
      {
        posts.map((post) =&gt; (
          &lt;li class="flow"&gt;
            &lt;ATPost post={post} /&gt;
          &lt;/li&gt;
        ))
      }
    &lt;/ul&gt;
  &lt;/div&gt;
&lt;/div&gt;
</code></pre>
<p>The main reason I wanted to tackle this region was because of that visually hidden <code>&lt;h1&gt;</code>. First here's the page, at the time of writing.</p>
<p><img src="https://piccalil.b-cdn.net/images/projects/bell-dot-bz-feed-page.jpg" alt="My feed page, looking very basic, showing posts in chronological order" /></p>
<p>I didn't want a big ol' heading on this page (at least initially), but I <em>do</em> need a top level heading, for assistive tech users, so my <a href="https://piccalil.li/blog/visually-hidden/">ever-useful CSS utility</a> helps a tonne here.</p>
<p>The rest of this file is pretty self explanatory, so let's dig into the component that renders each post.</p>
<pre><code>---
import {
  formatDate,
  convertATPrototocalURIToBlueskyURL,
} from '@repo/utils/helpers';

import MarkdownText from '@repo/ui/MarkdownText';

const { post } = Astro.props;
---

&lt;div class="at-post flow"&gt;
  &lt;MarkdownText content={post.content} className="flow" /&gt;
  {
    post.media.length
      ? post.media.map((media) =&gt; (
          &lt;&gt;
            {media.type === 'image' &amp;&amp; &lt;img src={media.src} alt={media.alt} /&gt;}

            {/* Like images, but this is specifically an open graph image */}
            {media.type === 'external' &amp;&amp; media.thumb &amp;&amp; (
              &lt;p&gt;
                &lt;a href={media.uri}&gt;
                  &lt;img src={media.thumb} alt={media.title} /&gt;
                &lt;/a&gt;
              &lt;/p&gt;
            )}

            {media.type === 'video' &amp;&amp; (
              &lt;video width="352" height="198" controls poster={media.thumbnail}&gt;
                &lt;source src={media.playlist} type="application/x-mpegURL" /&gt;
              &lt;/video&gt;
            )}
          &lt;/&gt;
        ))
      : null
  }

  &lt;p class="at-post__meta"&gt;
    &lt;time datetime={post.date}&gt;{formatDate(post.date, true)}&lt;/time&gt;
  &lt;/p&gt;

  &lt;dl class="at-post__stats cluster"&gt;
    &lt;dt&gt;Likes&lt;/dt&gt;
    &lt;dd&gt;{post.likes}&lt;/dd&gt;
    &lt;dt&gt;Reposts&lt;/dt&gt;
    &lt;dd&gt;{post.reposts}&lt;/dd&gt;
    &lt;dt&gt;Replies&lt;/dt&gt;
    &lt;dd&gt;{post.replies}&lt;/dd&gt;
  &lt;/dl&gt;

  &lt;p class="at-post__original-link"&gt;
    &lt;a href={convertATPrototocalURIToBlueskyURL(post.uri)}&gt;Original&lt;/a&gt;
  &lt;/p&gt;
&lt;/div&gt;
</code></pre>
<p>The first thing I do here is render the markdown text, generated in the <a href="https://piccalil.li/projects/personal-site/6">last article</a>, with my existing component. With the easy part done, I swiftly move on to looping over the <code>post.media</code> array and rendering appropriate markdown per embed type.</p>
<p>Following that, the post's date is rendered with the <code>&lt;time&gt;</code> element with like, repost and reply count rightly using a description list (<code>&lt;dl&gt;</code>) element to articulate the data appropriately.</p>
<p>The last part is a link out to Bluesky, which I'll bring in to show you:</p>
<pre><code>/**
 * Converts an AT Protocol URI to a Bluesky Web URL
 * @param {string} atUri - The at:// uri (e.g., at://did:plc.../app.bsky.feed.post/...)
 * @returns {string} The formatted bsky.app URL
 */
export function convertATPrototocalURIToBlueskyURL(uri) {
  // Pattern: at://(DID)/(COLLECTION)/(RKEY)
  const regex = /^at:\/\/(did:[^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/;
  const match = uri.match(regex);

  if (!match) {
    return 'Invalid AT Protocol post URI';
  }

  // Matches are in order because of array destructuring, so we use `_` to capture the un-needed part
  const [_, did, rkey] = match;
  return `https://bsky.app/profile/${did}/post/${rkey}`;
}
</code></pre>
<p>The aim of the game with this utility is to first match the parts of an AT protocol URI, then use those parts to return a web URL. Because every record in your Personal Data Server (PDS) has a <code>DID</code> (user ID) and <code>rkey</code>, it's a case of applying those URL parts and Bluesky does the rest. Handy.</p>
<p></p>
<h2>Wrapping up</h2>
<p>That's it! The AT protocol stuff looks incredibly complicated on the surface, but once you understand how PDS records work, it's a really elegant, straightforward system.</p>
<p>There will be a full AT protocol focused iteration of this website, once I've designed and implemented the new UI, so this stuff serves as a basic basis to build on. Hopefully it'll make the idea of implementing the AT protocol on <em>your</em> website more appealing too.</p>
<p>With AT protocol done, let's move on to my favourite part: the music collection, next.</p>
        
        ]]></description>
        
      </item>
    
      <item>
        <title>Personal website redesign project post: Loading AT protocol posts data</title>
        <link>https://piccalil.li/projects/personal-site/6/?ref=main-rss-feed</link>
        <dc:creator><![CDATA[Andy Bell]]></dc:creator>
        <pubDate>Wed, 01 Jul 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/projects/personal-site/6/?ref=main-rss-feed</guid>
        <description><![CDATA[<p>We're picking up some good momentum, so let's keep the flow going. After <a href="https://piccalil.li/projects/personal-site/4/">integrating WordPress to power the blog section</a>, it's now time to integrate a new feature of this website: AT Protocol posts.</p>
<p>I <em>could</em> go all in at this point and integrate posts, comments, interactions and comments on blog posts, but I'm not in the business of doing that until I <em>fully understand what I'm doing</em>. Throwing code at the wall to see what sticks is a one way high speed train to technical debt city.</p>
<p>If there ever is a time to experiment, it <em>is</em> on your personal site. I'm just trying to eradicate as much tech debt as possible in this project.</p>
<p>What I'm doing instead is what I outlined in planning, specifically where I worked out <a href="https://piccalil.li/projects/personal-site/2/#work-out-the-core-features">each iteration</a>. Here it is as a reminder:</p>
<p><img src="https://piccalil.b-cdn.net/images/projects/personal-site-core-features.jpg" alt="An Obsidian markdown file called &quot;core features and iterations.&quot; It lists a development roadmap across four iterations, including tasks like &quot;basic shell version of the site,&quot; &quot;look and feel design,&quot; &quot;AT protocol integration,&quot; and &quot;last.fm integration.&quot;" /></p>
<p>As per that planning, I'm still in <strong>iteration one</strong>, which means this phase of AT protocol integration is going to be a basic rendering of my AT protocol posts.</p>
<p>I say AT protocol and not <em>Bluesky</em> posts because it's worth remembering that Bluesky is the microblogging <em>app</em> built on the protocol. My data (posts) is outside of that platform, on my Personal Data Server (PDS). Right now, at the time of writing, that PDS is on Bluesky's infrastructure, but I <em>will</em> definitely be moving that to something I own, for sure.</p>
<p></p>
<h2>Rendering the posts</h2>
<p>In order to do this, I need create a new file in my <code>data</code> package — just like I did for the WordPress posts — and lean into the AT Protocol API, using their <a href="https://npmx.dev/package/@atproto/api">official package</a>.</p>
<p>Here's the file in whole. I'll break down the important bits:</p>
<pre><code>import { AtpAgent, RichText } from '@atproto/api';
import { getCache, setCache } from './memoryCache';

const agent = new AtpAgent({
  service: 'https://bsky.social',
});

export async function fetchAllATPosts() {
  const targetHandle = 'bell.bz';
  const cacheKey = 'atPosts';
  const cacheTimeout = 3600; // 3600 seconds is 1 hour
  const cached = getCache(cacheKey);

  if (cached) {
    return cached;
  }

  // Authentication is required
  await agent.login({
    identifier: targetHandle,
    password: process.env.BLUESKY_APP_PASSWORD,
  });

  let allPosts = [];
  let cursor = undefined;

  try {
    while (true) {
      // Fetch this cursor from the feed of items
      const response = await agent.getAuthorFeed({
        actor: targetHandle,
        cursor: cursor,
        limit: 100, // Max limit per cursor

        // For now, I'm just doing root level posts. Maybe as this   evolves I'll bring in replies too.
        filter: 'posts_no_replies',
      });

      // Loop each item but filter out reposts and quote posts
      for (const item of response.data.feed.filter(
        (x) =&gt;
          !(
            x?.reason?.$type === 'app.bsky.feed.defs#reasonRepost' ||
            x.post.embed?.$type === 'app.bsky.embed.record#view' ||
            x.post.embed?.$type === 'app.bsky.embed.recordWithMedia#view'
          )
      )) {
        const post = item.post;
        const parser = new RichText({ text: post.record.text || '' });

        await parser.detectFacets(agent);

        let postMarkdown = '';
        const externalEmbed = post.embed?.external || post.record.embed?.external;

        for (const segment of parser.segments()) {
          if (segment.isLink()) {
            let uri = segment.link?.uri;
            let linkText = segment.text;

            // Check if this link matches the external embed link
            // We compare URIs (or check if the embed exists) to get the full version
            if (
              externalEmbed &amp;&amp;
              (uri?.includes('..') ||
                (externalEmbed &amp;&amp; (uri?.includes('…') || uri === externalEmbed.uri)))
            ) {
              uri = externalEmbed.uri;
              linkText = externalEmbed.uri;
            }

            postMarkdown += `[${linkText}](${uri})`;
          } else if (segment.isMention()) {
            postMarkdown += `[${segment.text}](https://bsky.app/profile/${segment.text.replace('@', '')})`;
          } else {
            postMarkdown += segment.text;
          }
        }

        // Create a sensible return object type
        const postData = {
          uri: post.uri,
          cid: post.cid,
          content: postMarkdown,
          date: post.record.createdAt,
          likes: post.likeCount,
          reposts: (post.repostCount || 0) + (post.quoteCount || 0),
          replies: post.replyCount,
          media: [],
        };

        if (post.embed) {
        
          // If there are images, add to the return object
          if (post.embed.$type === 'app.bsky.embed.images#view') {
            postData.media = post.embed.images.map((img) =&gt; ({
              type: 'image',
              src: img.fullsize,
              alt: img.alt,
              thumb: img.thumb,
            }));
          }

          // If there are open graph images, surface those
          else if (post.embed.$type === 'app.bsky.embed.external#view') {
            postData.media.push({
              type: 'external',
              uri: post.embed.external.uri,
              title: post.embed.external.title,
              description: post.embed.external.description,
              thumb: post.embed.external.thumb,
            });
          }

          // If there are videos, add to the return object
          else if (post.embed.$type === 'app.bsky.embed.video#view') {
            postData.media.push({
              type: 'video',
              playlist: post.embed.playlist, // HLS stream (.m3u8)
              thumbnail: post.embed.thumbnail,
              cid: post.embed.cid,
            });
          }
        }

        allPosts.push(postData);
      }

      // Set the next cursor and break the loop if we're at the end
      cursor = response.data.cursor;
      if (!cursor) break;
    }

    // Cache so it doesn't take forever to work on this locally
    setCache(cacheKey, allPosts, cacheTimeout);
    return allPosts;
  } catch (error) {
    console.error('Error fetching feed:', error);
  }
}
</code></pre>

You may well recoil in *horror* at this code initially — I sure did initially while learning this stuff — but don't worry, I'll step us through it. 
<p>If you want to copy some code to use yourself, use this block and ignore the blocks in the breakdown.
</p>
<p>That’s a <em>lot</em> of code in one block. Let's break it down into chunks.</p>
<h3>The breakdown</h3>
<pre><code>import { AtpAgent, RichText } from '@atproto/api';
import { getCache, setCache } from './memoryCache';

const agent = new AtpAgent({
  service: 'https://bsky.social',
});
</code></pre>
<p>The first thing we do is set up dependencies: the agent (not one of <em>those</em> ones) which interfaces with the protocol for us and rich text capabilities that are used to tidy up content. The <code>memoryCache</code> parts are the <a href="https://piccalil.li/projects/personal-site/4/#lets-add-a-web-page">same as when I integrated the WordPress content</a>.</p>
<pre><code>const targetHandle = 'bell.bz';
const cacheKey = 'atPosts';
const cacheTimeout = 3600; // 3600 seconds is 1 hour
const cached = getCache(cacheKey);

if (cached) {
  return cached;
}

// Authentication is required
await agent.login({
  identifier: targetHandle,
  password: process.env.BLUESKY_APP_PASSWORD,
});
</code></pre>
<p>Here, I'm setting the target handle, the key for our memory cache and how long I want data to be cached for. I opted for an hour because I tend to work in short cycles when coding.</p>
<p>Next up, I attempt to load data from cache <em>first</em>, then check it. If there is data in cache, I can return it and move on. If not, the first thing to do is to get the agent to log in.</p>
<p>For the next part, we're going to be <em>within</em> the <code>while</code> loop.</p>
<pre><code>const response = await agent.getAuthorFeed({
  actor: targetHandle,
  cursor: cursor,
  limit: 100, // Max limit per cursor

  // For now, I'm just doing root level posts. Maybe as this evolves I'll
  // bring in replies too.
  filter: 'posts_no_replies',
});

</code></pre>
<p>Notice how I leave comments even for <em>myself</em>? I can look back on this in the future and immediately be up to speed</p>
<p>I have posted <em>a lot</em> on Bluesky so right off the bat, I need to use cursors to paginate over multiple chunks of posts. That's fine, because I'm keeping a track of it with the <code>cursor</code> variable. Eventually that <code>cursor</code> will be null, which in turn will break the <code>while</code> loop. Lovely stuff.</p>
<p>The only other bit to touch on is I'm getting only top level posts, not my replies. I'm not much of a <em>reply guy</em>, but I still don't want out of context posts on the feed because it's just <em>noise</em>.</p>
<pre><code>for (const item of response.data.feed.filter(
  (x) =&gt;
    !(
      x?.reason?.$type === 'app.bsky.feed.defs#reasonRepost' ||
      x.post.embed?.$type === 'app.bsky.embed.record#view' ||
      x.post.embed?.$type === 'app.bsky.embed.recordWithMedia#view'
    )
)) {
</code></pre>
<p>The data returned, for each page of data, has a <code>feed</code> array that I can now loop over. I do another pass at filtering here. Each line deals with:</p>
<ol>
<li>Reposts, which are classified as posts and I don't want posts <em>I</em> haven't written showing up in the feed</li>
<li>Quote posts, which I don't want to deal with <em>yet</em></li>
<li>Quote posts: same as #2, but with media by the <em>quoter</em></li>
</ol>
<pre><code>const post = item.post;
const parser = new RichText({ text: post.record.text || '' });

await parser.detectFacets(agent);

let postMarkdown = '';
const externalEmbed = post.embed?.external || post.record.embed?.external;

for (const segment of parser.segments()) {
  if (segment.isLink()) {
    let uri = segment.link?.uri;
    let linkText = segment.text;

    // Check if this link matches the external embed link
    // We compare URIs (or check if the embed exists) to get the full version
    if (
      externalEmbed &amp;&amp;
      (uri?.includes('..') ||
        (externalEmbed &amp;&amp; (uri?.includes('…') || uri === externalEmbed.uri)))
    ) {
      uri = externalEmbed.uri;
      linkText = externalEmbed.uri;
    }

    postMarkdown += `[${linkText}](${uri})`;
  } else if (segment.isMention()) {
    postMarkdown += `[${segment.text}](https://bsky.app/profile/${segment.text.replace('@', '')})`;
  } else {
    postMarkdown += segment.text;
  }
}
</code></pre>
<p></p>
<p>The aim of the game at this point is to generate a front-end friendly string of markdown that my existing infrastructure can deal with. In order to do that, I need to break down the <code>post.record.text</code> with the <code>RichText</code> utility, supplied by the <code>@atproto/api</code> package.</p>
<p>I can now loop over each segment of content and determine exactly what it <em>is</em>. For example, I check first to see if there's an <code>externalEmbed</code> — AKA a link — and build a markdown link string accordingly. If it's a mention, I create a nice link to that user's profile and finally if it's neither of those, I append raw text value to the markdown string.</p>
<pre><code>const postData = {
  uri: post.uri,
  cid: post.cid,
  content: postMarkdown,
  date: post.record.createdAt,
  likes: post.likeCount,
  reposts: (post.repostCount || 0) + (post.quoteCount || 0),
  replies: post.replyCount,
  media: [],
};
</code></pre>
<p>The most self explaining chunk of code here: I'm creating a nice flat return object structure that matches the front-end components.</p>
<pre><code>if (post.embed) {
  // If there are images, add to the return object
  if (post.embed.$type === 'app.bsky.embed.images#view') {
    postData.media = post.embed.images.map((img) =&gt; ({
      type: 'image',
      src: img.fullsize,
      alt: img.alt,
      thumb: img.thumb,
    }));
  }

  // If there are open graph images, surface those
  else if (post.embed.$type === 'app.bsky.embed.external#view') {
    postData.media.push({
      type: 'external',
      uri: post.embed.external.uri,
      title: post.embed.external.title,
      description: post.embed.external.description,
      thumb: post.embed.external.thumb,
    });
  }

  // If there are videos, add to the return object
  else if (post.embed.$type === 'app.bsky.embed.video#view') {
    postData.media.push({
      type: 'video',
      playlist: post.embed.playlist, // HLS stream (.m3u8)
      thumbnail: post.embed.thumbnail,
      cid: post.embed.cid,
    });
  }
}

allPosts.push(postData);
</code></pre>
<p>The next time I add something to these checks, I'll refactor this to be a <code>switch</code> statement. For now, I will live my life.</p>
<p>Now, the last bit of <em>data massaging</em> is left for this iteration. I'm checking over a couple of cases to render images or video depending on what embed content I've got to work with. Again, this is all about creating flat (as possible) structures for the front-end components to consume.</p>
<p>After all of that, I push that post into the higher level array, which we'll cache and return.</p>
<h2>Wrapping up</h2>
<p>That's the data sorted, so the next thing to do is wire it up to the website itself. I'll tackle that in the next one!</p>
        
        ]]></description>
        
      </item>
    
      <item>
        <title>The Index: Issue #188</title>
        <link>https://piccalil.li/the-index/188/?ref=main-rss-feed</link>
        <dc:creator><![CDATA[Andy Bell]]></dc:creator>
        <pubDate>Fri, 26 Jun 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/the-index/188/?ref=main-rss-feed</guid>
        <description><![CDATA[<h2><a href="https://mindfuldesign.xyz/by-humans/?utm_source=the-index&amp;utm_medium=newsletter">By humans, for humans</a></h2>
<blockquote>
<p>We’re approaching—or arguably are <em>in</em>—an era where ‘made by humans’ is a differentiator. A notion that something beyond money and prompts was put into whatever the heck it is we’re using or consuming or enjoying. We should never lose sight of that.</p>
</blockquote>
<h2><a href="https://overreacted.io/a-social-filesystem/?utm_source=the-index&amp;utm_medium=newsletter">A social filesystem</a></h2>
<p>A very good explainer of how the AT protocol stuff actually works.</p>
<h2><a href="https://bobdahacker.com/blog/fifa-hack?utm_source=the-index&amp;utm_medium=newsletter">I could've rickrolled the entire FIFA World Cup. All I needed was my ID.</a></h2>
<p>Actually mind-blowing read. Never use client-side only auth, pals.</p>
<h2><a href="https://www.matuzo.at/blog/2026/content-aware-headings?utm_source=the-index&amp;utm_medium=newsletter">Context-aware headings in HTML</a></h2>
<p>It's about <em>time</em> we got this!</p>
<h2><a href="https://usebilly.app/en?utm_source=the-index&amp;utm_medium=newsletter">Billy</a></h2>
<p>One for the freelancers/moonlighters amongst us who are sick of over-complicated invoice solutions.</p>
<h2><a href="https://piccalil.li/blog/some-practical-examples-of-view-transitions-to-elevate-your-ui/?utm_source=the-index&amp;utm_medium=newsletter">Some practical examples of view transitions to elevate your UI</a></h2>
<p>Here's one from the <a href="https://piccalil.li/blog/">Piccalilli archives</a> that you might have missed to wrap up this issue.</p>
<hr />
<p>P.S. <a href="https://lowercase.name/?utm_source=the-index&amp;utm_medium=newsletter">this is a good website</a> from <a href="https://personalsit.es/?utm_source=the-index&amp;utm_medium=newsletter">personalsit.es</a>.</p>
        
        <h2>Sponsor message</h2><a href="https://2026.ffconf.org?ref=piccalilli"><img src="https://piccalil.b-cdn.net/images/ads/ffconf-2026.jpg" alt="FF conf 2026. A room full of people who still give a damn about the web and its community - with talks meant to be experienced, not bookmarked" /></a><p><strong>FFConf is a room full of people who still give a damn about the web and its community - with talks meant to be experienced, not bookmarked.</strong></p>
<p>Sessions announced, so far: motion in the browser, mad CSS, systems design lessons from a chicken restaurant, and The AI hype, layoffs, a barely-recognisable industry and the search for being human in tech.</p>
<p>Limited early bird tickets available now.</p>
<p><a href="https://2026.ffconf.org?ref=piccalilli">Get your early bird ticket</a></p>]]></description>
        
      </item>
    
      <item>
        <title>Publishing on the Atmosphere with Standard.site</title>
        <link>https://piccalil.li/blog/publishing-on-the-atmosphere-with-standardsite/?ref=main-rss-feed</link>
        <dc:creator><![CDATA[Declan Chidlow]]></dc:creator>
        <pubDate>Thu, 25 Jun 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/blog/publishing-on-the-atmosphere-with-standardsite/?ref=main-rss-feed</guid>
        <description><![CDATA[<p><a href="https://standard.site/">Standard.site</a> provides a set of lexicons for publishing long-form content on the internet using the same protocol used under the hood by Bluesky.</p>
<p>If you are wondering what 'lexicons' and 'the Atmosphere' are, don't fret. This article will explain what they mean, why you should care about Standard.site, and walk you through exactly how you can implement Standard.site using some simple JavaScript or a plugin for your favourite content management system.</p>
<h2>What Standard.site is and why you should care</h2>
<p>If Bluesky is the network's answer to short-form microblogging, think of Standard.site as its equivalent for blogs, newsletters, and long-form journalism.</p>
<p>At its core, Standard.site is an open schema that dictates how articles and essays should be formatted as data. When you publish a blog post normally, it lives on your website and relies on scrapers or RSS feeds to be shared. By adopting the Standard.site lexicon, your long-form content becomes a natively understood piece of data on the decentralised web.</p>
<p>One of the most overt benefits we get from defining Standard.site lexicons for our publication are enhanced rich embeds on Bluesky, like this:</p>
<p><img src="https://piccalil.b-cdn.net/images/blog/standard-site-bluesky-post.png" alt="A mock bluesky post showing a linked blog post, featuring a richer UI experience with direct call to action to view publisher" /></p>
<p>We also get many other benefits. For example, I was surprised to see that the professional identity network Sifa ID <a href="https://sifa.id/p/vale.rocks#publications">displayed my posts upon my profile</a>. It was also pleasant to see my posts naturally populate on readers like <a href="https://pckt.blog/">pckt</a>, <a href="https://docs.surf/">Docs.surf</a>, <a href="https://potatonet.app/">potatonet</a>, and <a href="https://leaflet.pub/">Leaflet</a> without any additional work on my part.</p>
<p>The strength of AT Protocol is that data is interoperable and can be shared, which plays into the strength of Standard.site, which is that a single set of well structured-schemas. The result is that various indexers and tools can all work with the available data in their own ways, knowing how it'll be structured. Your content can be moved between hosts without losing your data or the audience you've built, and there is no single controlling authority. To get further into this, however, we must first establish an understanding of the AT Protocol.</p>
<p></p>
<h2>Understanding the AT Protocol</h2>
<p>To implement Standard.site, you will want to understand the Authenticated Transfer Protocol (known colloquially as the 'AT Protocol' or 'atproto') at least at a surface level. The AT Protocol is a decentralised system designed to give users ownership of their data.</p>
<p>To explain it at its simplest, you have a Personal Data Server (PDS) which hosts user accounts. Currently, the largest PDS is provided by Bluesky, but anyone can run their own. A PDS holds lots of user accounts, and each user account can hold records, which are data.</p>
<p>Each user account can be identified by a globally unique DID (Decentralised Identifier) that acts as their permanent ID. A DID looks like this: <code>did:plc:7qg6mz2xtzozxkgbcvf4pdnu</code>. Each account also has a handle, which comes in the form of a DNS record. We can see this in that people on Bluesky's PDS who haven't configured a custom domain for their account have a handle like this: <code>bsky.bsky.social</code>. If you navigate to that in a browser, it'll take you to the Bluesky page: <a href="https://bsky.bsky.social/"></a><a href="https://bsky.bsky.social">https://bsky.bsky.social</a>.</p>
<p>Each account features a data repository that holds collections of JSON records. These JSON records must follow specific structures called lexicons. Lexicons are just schemas, like JSON-Schema or OpenAPI, which define how the JSON must be structured and formatted. Records are put into 'collections', which we can think of as folders. A collection is identified by a Namespace Identifier (NSID) which makes reference to a domain to identify schemas.</p>
<p>Let’s run through an example with Bluesky so we can really get a handle on things. When a user signs up to Bluesky, a <code>self</code> record is created in the <code>app.bsky.actor.profile</code> collection of that user's data repository with information about the account, like its name and profile description. Piccalilli's looks something like this:</p>
<pre><code>{
  "uri": "at://did:plc:lyk2pixxcmyeu4jrapaq26fy/app.bsky.actor.profile/self",
  "cid": "bafyreihzo3igobmunvk6tmsaqgyatyw5gona4kiayvm2f62lymc5dzqjvu",
  "value": {
    "$type": "app.bsky.actor.profile",
    "createdAt": "2024-08-01T12:44:51.324Z",
    "description": "Level up your front-end skills. Stay for the approachable, friendly content and go away with transferable skills you can use day to day.",
    "displayName": "Piccalilli"
  }
}
</code></pre>
<p>Then, every time a post is made, or the Piccalilli account likes something, or blocks someone, or does anything else on Bluesky, a new record is created to represent that action. For instance, when Piccalilli reposts something, a new record is created under the <code>app.bsky.feed.repost</code> collection.</p>
<p><strong>Almost everything is a record, inside a collection, under a user account (identified by a DID), on a PDS.</strong></p>
<p>Notably, everything on ATProto is public. There is no concept of private records, which means we can go out and inspect or reference all the data on the protocol. There are a number of tools for inspecting AT Protocol data, but <a href="https://atproto.at/">Taproot</a> and <a href="https://pdsls.dev/">PDSLs</a> are my favourites. Search for your account handle, and you'll be greeted by your underlying records. Have a poke around to help wrap your head around the structure and how everything fits together.</p>
<p></p>
<h2>The two core records</h2>
<p>Now we've (hopefully) got at least (somewhat) of a (fledgeling) understanding of AT Protocol, we can start hooking up Standard.site. To get started with Standard.site, we need to create two specific types of records in your AT Protocol repository:</p>
<ol>
<li>A Publication Record, which defines information about our publication itself.</li>
<li>Document Records, which contain information about individual articles themselves.</li>
</ol>
<p>It is these records that Bluesky and the rest of the Atmosphere will reference. You can <a href="https://atproto.com/guides/writing-data#writing-data">create them any one of a number of ways</a>. AT Protocol is very open, and you can create records via a variety of methods, but for the purposes of this article, I'll be showing a JavaScript approach using the official <a href="https://npmx.dev/package/@atproto/api"><code>@atproto/api</code></a> npm package.</p>
<p>You will need to authenticate to create these records. The easiest way to do so is by creating an app password under Privacy and Security in Bluesky's settings. An app password gives access to your account and looks like this: <code>fg2g-xob3-xl78-5ezy</code>.</p>
<div><h2>FYI</h2>
<p>An app password gives <em>full</em> access to your account and bypasses multi-factor authentication. Be <em>extremely</em> careful with it. It is a secret, and you should take extreme care of it. This illustrative code shows including the app password inline, but you should consider putting it in an environment variable.</p>
<p>If you think your app password has been made public, you should revoke it, which can be done through the same interface you created it.</p>
</div>
<h3>Publication record</h3>
<p>The first step in support is having a publication record adhering to the <a href="https://standard.site/docs/lexicons/publication/"><code>site.standard.publication</code> lexicon</a>. You only need to create this record once per-publication.</p>
<p>Using the AT Protocol SDK, you can authenticate and create this underlying JSON record for your site with the script below, replacing the template values here with your publication's details.</p>
<p>For the purpose of illustration, this script only sets required properties.</p>
<pre><code>import { AtpAgent } from "@atproto/api";

// Initialise the agent (use your specific PDS if not on Bluesky)
const agent = new AtpAgent({ service: "&lt;https://bsky.social&gt;" });

async function createPublicationRecord() {
  // 1. Authenticate (Always use an App Password, never your main password)
  await agent.login({
    identifier: "your-handle.bsky.social",
    password: "your-app-password",
  });

  const did = agent.session.did;

  // 2. Define the Publication Record
  const publicationRecord = {
    $type: "site.standard.publication",
    url: "&lt;https://example.com&gt;",
    name: "My Awesome Blog",
  };

  // 3. Write the record to your repository
  try {
    const response = await agent.com.atproto.repo.createRecord({
      repo: did,
      collection: "site.standard.publication",
      record: publicationRecord,
    });

    console.log("Publication record created!");
    console.log("Your AT-URI is:", response.data.uri);
  } catch (error) {
    console.error("Failed to create publication:", error);
  }
}

createPublicationRecord();
</code></pre>
<p>If this script was successful, it should output a message reading 'Publication record created!', followed by an AT-URI. Save this, because we'll need it later. In the future if we need to amend this record, we can <a href="https://atproto.com/guides/writing-data#updating-records">revise the record directly</a>.</p>
<h3>Theming</h3>
<p>Though the above script is great, we can take it a bit further and add some more pizazz by <a href="https://standard.site/docs/lexicons/theme/">defining a theme</a>. You can create a theme to lend some more style to how your content displays in readers and how Bluesky embeds it. This is done by adding theming fields to your publication record. You need to specify a <code>background</code>, <code>foreground</code>, <code>accent</code>, and <code>accentForeground</code>. If you're setting any of these values, you must set <em>all</em> of them.</p>
<p>Bluesky uses <code>accent</code> and <code>accentForeground</code> like so:</p>
<p><img src="https://piccalil.b-cdn.net/images/blog/standard-site-theme-diagram.png" alt="A diagram pointing out the accent and accentForeground in the context of the call to action button" /></p>
<p>You should check that your foreground and background and accent and accent foreground all have appropriate contrast. Bluesky previously took these values directly, but now they do <a href="https://bsky.app/profile/esb.lol/post/3mnilfmgqns2d">some contrast adjustment of their own</a>.</p>
<p>You, unfortunately, cannot change the text which appears on the Bluesky embed button, which will always be 'View Publication' if you publish yourself. Some external Standard.site enabled services have their own special buttons with custom text and icons, but these are hard coded in the Bluesky client.</p>
<h3>Verifying your publication</h3>
<p>Next, we have the optional step of creating a file at <code>/.well-known/site.standard.publication</code> containing the AT-URL outputted by our record creation script. This verifies that your domain controls your publication record.</p>
<p>Most services, Bluesky included, don't require this verification. Indeed, some hosts might not let you write to the <code>/.well-known</code> path. However, if you <em>can</em> create a file named <code>site.standard.publication</code> within <code>/.well-known</code> and put your AT-URL within it, your publication will be more widely supported. This verification only needs to be done once.</p>
<p>You can check this has been created correctly by going to your URL on your site. For example, for my personal website, I can visit <a href="https://vale.rocks/.well-known/site.standard.publication">https://vale.rocks/.well-known/site.standard.publication</a> in my browser and see my AT-URI:</p>
<pre><code>at://did:plc:7qg6mz2xtzozxkgbcvf4pdnu/site.standard.publication/3mn2c332ulp2u
</code></pre>
<h3>Document records</h3>
<p>Now that your publication record exists, you need to create per-document records following the <a href="https://standard.site/docs/lexicons/document/"><code>site.standard.document</code> lexicon</a>. Every document needs its own record.</p>
<p>Standard.site supports having your document's content in the record, which is the approach that Offprint, pckt, Leaflet, and some other publishing platforms use. Whether you do this is up to you.</p>
<p>If you <em>do</em> include the content, then it can be displayed natively in Standard.site reader applications, and Bluesky embeds will provide a reading time estimate. For the purpose of this script, I'll again only be including required properties.</p>
<pre><code>import { AtpAgent } from "@atproto/api";
const agent = new AtpAgent({ service: "&lt;https://bsky.social&gt;" });

async function publishDocumentRecrd() {
  await agent.login({
    identifier: "your-handle.bsky.social",
    password: "your-app-password",
  });

  const did = agent.session.did;

  // 1. Define the Document Record
  const documentRecord = {
    $type: "site.standard.document",
    site: `at://your-did/site.standard.publication/your-pub-rkey`, // Full AT-URI of your publication
    title: "My New Post",
    publishedAt: "2026-06-11T00:00:00.000Z",
  };

  // 2. Write the record to your repository
  try {
    const response = await agent.com.atproto.repo.createRecord({
      repo: did,
      collection: "site.standard.document",
      record: documentRecord,
    });

    console.log("Success! Document record published to the Atmosphere:");
    console.log(response.data.uri);
  } catch (error) {
    console.error("Failed to publish document:", error);
  }
}

publishDocumentRecord();
</code></pre>
<p>If this script was successful, it should output a message reading 'Success! Document record published to the Atmosphere:', followed by an AT-URI. Save this, because we need it to verify the document.</p>
<h3>Verifying your document</h3>
<p>To complete the two-way verification, the HTML <code>&lt;head&gt;</code> of your live article must contain a link tag pointing back to the document record you just created:</p>
<pre><code>&lt;link rel="site.standard.document" href="at://did:plc:your-did/site.standard.document/the-record-rkey" /&gt;
</code></pre>
<p>Once these ends are tied together, your webpage and document record point to each other, and clients across the decentralised web can seamlessly reference the record.</p>
<h3>Adding images</h3>
<p>If you look through the Standard.site docs at all, you might notice references to icons and cover images. To make use of these, <a href="https://atproto.com/guides/images-and-video">we must upload a <em>blob</em></a>, which is what unstructured data (like images) within a repository are called.</p>
<p>We need to upload the blob first, so that we can refer to it with a reference in our record. Here is an example of uploading an image as a blob and then referencing it to use it as a cover image.</p>
<pre><code>import fs from "fs";
import { AtpAgent } from "@atproto/api";
const agent = new AtpAgent({ service: "&lt;https://bsky.social&gt;" });

async function uploadImageAndPublishDocument() {
  await agent.login({
    identifier: "your-handle.bsky.social",
    password: "your-app-password",
  });

  const did = agent.session.did;

  // 1. Read the local image file into a buffer
  const imageBuffer = fs.readFileSync("./path/to/your/cover.jpg");

  // 2. Upload the blob to your repository
  const { data: blobResponse } = await agent.com.atproto.repo.uploadBlob(
    imageBuffer,
    { encoding: "image/jpeg" }
  );

  console.log("Blob successfully uploaded!");

  // 3. Define the Document Record, attaching the returned blob reference
  const documentRecord = {
    $type: "site.standard.document",
    site: `at://your-did/site.standard.publication/your-pub-rkey`,
    title: "My New Post with a Cover Image",
    publishedAt: "2026-06-18T12:00:00.000Z",
    cover: blobResponse.blob, // This links the blob to your document
  };

  // 4. Write the document record to your repository
  try {
    const response = await agent.com.atproto.repo.createRecord({
      repo: did,
      collection: "site.standard.document",
      record: documentRecord,
    });

    console.log("Document record with cover image published:");
    console.log(response.data.uri);
  } catch (error) {
    console.error("Failed to publish document:", error);
  }
}

uploadImageAndPublishDocument();
</code></pre>
<p></p>
<h2>Setting up Standard.site on CMS platforms</h2>
<p>If writing JavaScript to push records manually sounds tedious, or you're already using a major content management system, you might prefer to have the process handled for you. How you integrate Standard.site depends very much on how your own site is built, and some platforms have ready-made integrations via plugins that handle all of the above behind the scenes:</p>
<ul>
<li><strong>WordPress:</strong> Has <a href="https://wordpress.org/plugins/atmosphere/">the ATmosphere plugin</a> and <a href="https://wordpress.wireservice.net/">the Wireservice plugin</a>.</li>
<li><strong>CraftCMS:</strong> Has <a href="https://plugins.craftcms.com/standard-site">a plugin simply called Standard.site</a>.</li>
<li><strong>Obsidian:</strong> Has a <a href="https://github.com/SootyOwl/obsidian-standard-site">community plugin</a>.</li>
<li><strong>Static sites</strong>: Generators like 11ty, Hugo, Astro, and Jekyll can use a dedicated CLI tool called <a href="https://sequoia.pub/">Sequoia</a>.</li>
</ul>
<h2>Checking it works</h2>
<p>Obviously, creating all these records and configuring all this Standard.site business is a bit useless if it doesn't actually work. The easiest way to test is by just plopping a link to a post on Bluesky and hoping it embeds, but if it doesn't, it can feel a tad opaque when it comes to figuring out what went wrong.</p>
<p>To rectify this, you can <a href="https://site-validator.fly.dev/">make use of Standard.site Validator</a> by <a href="https://octet-stream.net/">Thomas Karpiniec</a>. Consider returning to <a href="https://atproto.at/">Taproot</a> or <a href="https://pdsls.dev/">PDSLs</a> to study and review your records. Both will let you know if anything fails to validate and where things went awry.</p>
<h2>Further reading</h2>
<p>For some further reading, Piccalilli's own <a href="https://wil.to/">Mat Marquis</a>, creator of the <a href="https://piccalil.li/javascript-for-everyone">JavaScript for Everyone</a> course, has written his own posts documenting his <a href="https://wil.to/posts/standard-site/">understanding of</a> and <a href="https://wil.to/posts/implementing-standard-site/">implementation of</a> Standard.site on his own blog.</p>
<p>If you want to have multiple Standard.site publications under a single domain, then Jason Lengstorf has <a href="https://codetv.dev/blog/multiple-standard-site-publications-on-one-website">an in-depth guide on the Code.TV blog</a>.</p>
        
        ]]></description>
        
      </item>
    
    </channel>
  </rss>
