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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Josselin Perrus's personal site: an Astro 7 static site served by Cloudflare Wor
- `src/fonts/`: the self-hosted fonts, DejaVu Sans Mono and Newsreader, subset by `scripts/subset-fonts.py`, with their licences. The site loads no outside fonts.
- `src/content/writing/`: git submodule of [meaningfool-writing](https://github.com/meaningfool/meaningfool-writing), which holds every article, activity log and image. Read its `AGENTS.md` before editing content.
- `src/content.config.ts`: the content collection, built from `articles/*.md` and `daily-logs/*.md` in the submodule.
- `src/pages/articles/[slug].astro`: the page of every article and activity log. The Markdown goes through the plugins in `src/lib/` set in `astro.config.mjs`: the `# Title` line is dropped, the headings are renumbered from h2, and `rehype-article.mjs` shapes figures, YouTube embeds (moved to youtube-nocookie.com) and tables for `src/styles/prose.css`. In a table, a row whose cells are all bold is highlighted, and a paragraph starting with `Table:` right after it, past a blank line, is its caption. An image alone in its paragraph is captioned with its title, or with its alt text when that has a space.
- `src/data/projects.ts`: the projects, with their screenshots in `src/assets/projects/`, and which articles belong to each, by article id.
- `docs/document-sharing.md`: how the encrypted portfolio links on `/documents/` work and how to create them.
- `wrangler.jsonc`: the Cloudflare Worker `meaningfool`, which serves `dist/` as static assets.
- `worker/index.js`: the only Worker code. It answers byte-range requests for the videos in `/images/`, which Safari needs to play them.
Expand Down
18 changes: 17 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
// @ts-check
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import { unified } from '@astrojs/markdown-remark';
import remarkBreaks from 'remark-breaks';
import rehypeRaw from 'rehype-raw';
import remarkDropTitle from './src/lib/remark-drop-title.mjs';
import remarkHeadingLevels from './src/lib/remark-heading-levels.mjs';
import rehypeArticle from './src/lib/rehype-article.mjs';
import trailingSlashRedirects from './src/lib/trailing-slash-redirects.mjs';
import { postLastModified } from './src/lib/posts.mjs';

const lastModified = postLastModified(fileURLToPath(new URL('./src/content/writing', import.meta.url)));

// Astro keeps each post's rendered HTML between builds, in node_modules/.astro, which the deploy workflow restores from
// its cache, and renders a post again only when the post or this config changes. The Markdown plugins' code and the
// installed packages aren't part of the config it compares, so a hash of them is, as rehypeArticle's options.
const markdownSources = ['src/lib/remark-drop-title.mjs', 'src/lib/remark-heading-levels.mjs', 'src/lib/rehype-article.mjs', 'bun.lock'];
const markdownDigest = createHash('sha256')
.update(markdownSources.map((file) => readFileSync(new URL(`./${file}`, import.meta.url), 'utf8')).join('\0'))
.digest('hex');

// https://astro.build/config
export default defineConfig({
markdown: {
processor: unified({
remarkPlugins: [remarkBreaks, remarkDropTitle],
remarkPlugins: [remarkBreaks, remarkDropTitle, remarkHeadingLevels],
// rehype-raw first, so that rehypeArticle also sees the HTML written in the Markdown, such as embeds
rehypePlugins: [rehypeRaw, [rehypeArticle, { markdownDigest }]],
}),
shikiConfig: { theme: 'github-light' }, // code blocks sit on the white page, in a light frame (prose.css)
},
integrations: [sitemap({
filter: (page) => !/^\/documents\/?$/.test(new URL(page).pathname),
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"@astrojs/rss": "^4.0.19",
"astro": "^7.4.3",
"fontkitten": "^1.0.3",
"rehype-raw": "^7.0.0",
"remark-breaks": "^4.0.0",
"sharp": "^0.35.4"
},
Expand Down
Binary file added src/assets/projects/agent-sdks.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 added src/assets/projects/data-lineage.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 added src/assets/projects/voice-todos.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 74 additions & 0 deletions src/components/PrevNext.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
// Previous and Next at the end of a post: the older post on the left, the newer one on the right.
import type { CollectionEntry } from 'astro:content';

type Post = CollectionEntry<'writing'>;

interface Props {
previous?: Post;
next?: Post;
}

const { previous, next } = Astro.props;
const links = [
{ post: previous, label: '← Previous', rel: 'prev' },
{ post: next, label: 'Next →', rel: 'next' },
];
---

{(previous || next) && (
<nav class="prevnext" aria-label="Previous and next">
{links.map(({ post, label, rel }) => (
<div>
{post && (
<a href={`/articles/${post.id}/`} rel={rel}>
<small>{label}</small>
<span>{post.data.title}</span>
</a>
)}
</div>
))}
</nav>
)}

<style>
.prevnext {
display: flex;
gap: 24px;
padding-top: 20px;
border-top: 1px solid var(--line);
}

.prevnext > div {
flex: 1 1 0;
}

.prevnext > div:last-child {
text-align: right;
}

a {
display: grid;
gap: 6px;
align-content: start;
color: var(--ink);
text-decoration: none;
}

small {
font-size: 12px;
line-height: 18px;
letter-spacing: 0.04em;
color: var(--muted);
}

span {
font-size: 15px;
line-height: 22px;
}

a:hover span {
text-decoration: underline;
text-decoration-color: var(--accent);
}
</style>
90 changes: 90 additions & 0 deletions src/components/ProjectPanel.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
// "Part of the project", after an article that belongs to one: its screenshot, name, one line and link. On phones the
// screenshot goes above the text, full width.
import { Image } from 'astro:assets';
import type { Project } from '../data/projects';

interface Props {
project: Project;
}

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

<aside class="project-panel" aria-label="Part of the project">
<Image src={project.screenshot} alt="" widths={[352, 720]} sizes="(max-width: 900px) 100vw, 176px" />
<div>
<p class="label">Part of the project</p>
<p class="name">{project.name}</p>
<p class="line">{project.description}</p>
<a href={project.link}>{project.linkText} ↗</a>
</div>
</aside>

<style>
.project-panel {
display: flex;
align-items: start;
gap: 20px;
padding: 20px;
background: var(--panel);
}

img {
flex: 0 0 176px;
width: 176px;
height: 110px;
object-fit: cover;
border: 1px solid var(--line);
}

div {
display: grid;
gap: 6px;
}

.label {
font-size: 11px;
line-height: 16px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}

.name {
font-size: 18px;
line-height: 26px;
letter-spacing: -0.04em;
}

.line {
font-size: 13px;
line-height: 21px;
color: var(--muted);
}

a {
justify-self: start;
padding-top: 4px;
font-size: 12px;
line-height: 18px;
text-decoration: none;
}

a:hover {
text-decoration: underline;
}

@media (max-width: 900px) {
.project-panel {
flex-direction: column;
}

img {
flex: none;
width: 100%;
height: auto;
aspect-ratio: 16 / 10;
}
}
</style>
77 changes: 71 additions & 6 deletions src/data/projects.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,72 @@
// The projects the home shows, one entry each. For now only what the share image's project count needs; the home and
// article pages (#31, #32) add each project's details.
export const projects = [
{ slug: 'voice-todos', name: 'Voice Todos' },
{ slug: 'agent-sdks', name: 'Agent SDKs for the Rest of Us' },
{ slug: 'data-lineage', name: 'Data Lineage' },
// The projects shown on the site, newest first: the "Part of the project" panel and the project in the meta line of
// an article (#32), the Projects tiles of the home (#31) and the project count of the share images (#34).
//
// Which articles belong to which project is kept here, by article id (the file name without its date), because the
// articles' frontmatter in meaningfool-writing has no field for it.
import type { ImageMetadata } from 'astro';
import voiceTodos from '../assets/projects/voice-todos.jpg';
import agentSdks from '../assets/projects/agent-sdks.jpg';
import dataLineage from '../assets/projects/data-lineage.jpg';

export interface Project {
slug: string;
name: string;
/** A shorter name, for narrow columns such as the home's Writing list. */
shortName: string;
type: 'App' | 'Report';
/** The month it came out, as YYYY-MM. */
date: string;
/** One line that says what it is. */
description: string;
link: string;
/** The text of the link, without the ↗. */
linkText: string;
/** A 16:10 screenshot. */
screenshot: ImageMetadata;
/** The ids of the articles written about it. */
articles: string[];
}

export const projects: Project[] = [
{
slug: 'voice-todos',
name: 'Voice Todos',
shortName: 'Voice Todos',
type: 'App',
date: '2026-05',
description: 'Talk, and your todo list builds itself as you speak.',
link: 'https://voice-todos.meaningfool.net/',
linkText: 'Try Voice Todos',
screenshot: voiceTodos,
articles: ['edit-collections-generative-ai', 'structured-extraction', 'thoughts-on-human-ai-collaboration'],
},
{
slug: 'agent-sdks',
name: 'Agent SDKs for the Rest of Us',
shortName: 'Agent SDKs',
type: 'Report',
date: '2026-02',
description: 'A visual guide to agent frameworks for people who build products.',
link: 'https://agent-frameworks-report.meaningfool.net/',
linkText: 'Read the report',
screenshot: agentSdks,
articles: ['agent-frameworks-for-the-rest-of-us'],
},
{
slug: 'data-lineage',
name: 'Data Lineage',
shortName: 'Data Lineage',
type: 'App',
date: '2025-12',
description: 'CSV files in, an interactive lineage graph out.',
link: 'https://lineage.meaningfool.net/',
linkText: 'Try Data Lineage',
screenshot: dataLineage,
articles: ['antigravity_is_onto_something'],
},
];

/** The project an article belongs to, if any. */
export function projectOf(articleId: string): Project | undefined {
return projects.find((project) => project.articles.includes(articleId));
}
63 changes: 63 additions & 0 deletions src/lib/excerpt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };

function decode(html: string): string {
return html.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (match, code: string) => {
if (code[0] !== '#') return ENTITIES[code.toLowerCase()] ?? match;
const point = code[1].toLowerCase() === 'x' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
return point <= 0x10ffff ? String.fromCodePoint(point) : match;
});
}

// A tag, whose quoted attribute values may hold a ">".
const TAG = /<(?:[^>"']|"[^"]*"|'[^']*')*>/g;
const textOf = (html: string) => decode(html.replace(TAG, '')).replace(/\s+/g, ' ').trim();

/** The HTML without its lists, quotes, tables and figures, innermost first, so that only the text's own paragraphs stay. */
function withoutBlocks(html: string): string {
const innermost = /<(ul|ol|blockquote|table|figure)\b[^>]*>(?:(?!<\/?(?:ul|ol|blockquote|table|figure)\b)[\s\S])*<\/\1>/g;
for (let previous = ''; previous !== html; ) [previous, html] = [html, html.replace(innermost, ' ')];
return html;
}

/** A paragraph's text. Its line breaks become spaces, or full stops between a line and one that starts a sentence. */
function paragraphText(html: string): string {
return html.split(/<br\s*\/?>/).map(textOf).filter(Boolean).reduce((text, line) => {
if (!text) return line;
return /[\p{L}\p{N}]$/u.test(text) && /^\p{Lu}/u.test(line) ? `${text}. ${line}` : `${text} ${line}`;
}, '');
}

const SENTENCE_END = /[.!?…]["”’)]?$/;

/**
* A post's meta description when its frontmatter has none, from its rendered HTML: the text of its first paragraph,
* and of the next when the first is very short. Paragraphs in lists, quotes, tables and figures don't count. A
* paragraph that leads into a list ("…the likes:") is cut back to its last full sentence, or ends with an ellipsis,
* unless it is a short label, which is skipped. The result is cut at a word to stay within `max` characters, and is
* empty when the post has no paragraph to take.
*/
export function excerpt(html: string, max = 160): string {
let text = '';
for (const [, inner] of withoutBlocks(html).matchAll(/<p>([\s\S]*?)<\/p>/g)) {
let paragraph = paragraphText(inner);
if (!SENTENCE_END.test(paragraph)) {
// Its whole sentences, unless that leaves little more than an abbreviation such as "e.g."
const whole = paragraph.match(/^.*[.!?…]["”’)]?(?=\s)/)?.[0];
const sentences = whole && whole.length >= 40 ? whole : undefined;
// A short label such as "Readings:" over a list says nothing on its own: skip it, or stop at it.
if (!sentences && paragraph.length < 50) {
if (text) break;
continue;
}
paragraph = sentences ?? `${paragraph.replace(/[\s,;:–—-]+$/, '')}…`;
text = text ? `${text} ${paragraph}` : paragraph;
break;
}
text = text ? `${text} ${paragraph}` : paragraph;
if (text.length >= 50) break;
}
if (text.length <= max) return text;
const cut = text.slice(0, max);
const end = cut.lastIndexOf(' ');
return `${(end > 0 ? cut.slice(0, end) : cut.slice(0, max - 1)).replace(/[\s,;:.–—-]+$/, '')}…`;
}
Loading
Loading