diff --git a/AGENTS.md b/AGENTS.md index 2ee8e4a..ae7c854 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/astro.config.mjs b/astro.config.mjs index d5e09bf..9b68b24 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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), diff --git a/bun.lock b/bun.lock index eca2ecb..7710314 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,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", }, diff --git a/package.json b/package.json index e2d57d9..5f99bc5 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/assets/projects/agent-sdks.jpg b/src/assets/projects/agent-sdks.jpg new file mode 100644 index 0000000..bca5c5e Binary files /dev/null and b/src/assets/projects/agent-sdks.jpg differ diff --git a/src/assets/projects/data-lineage.jpg b/src/assets/projects/data-lineage.jpg new file mode 100644 index 0000000..87cd77e Binary files /dev/null and b/src/assets/projects/data-lineage.jpg differ diff --git a/src/assets/projects/voice-todos.jpg b/src/assets/projects/voice-todos.jpg new file mode 100644 index 0000000..27050ee Binary files /dev/null and b/src/assets/projects/voice-todos.jpg differ diff --git a/src/components/PrevNext.astro b/src/components/PrevNext.astro new file mode 100644 index 0000000..d50e866 --- /dev/null +++ b/src/components/PrevNext.astro @@ -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) && ( + +)} + + diff --git a/src/components/ProjectPanel.astro b/src/components/ProjectPanel.astro new file mode 100644 index 0000000..52f3020 --- /dev/null +++ b/src/components/ProjectPanel.astro @@ -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; +--- + + + + diff --git a/src/data/projects.ts b/src/data/projects.ts index 08a9da5..cb2b177 100644 --- a/src/data/projects.ts +++ b/src/data/projects.ts @@ -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)); +} diff --git a/src/lib/excerpt.ts b/src/lib/excerpt.ts new file mode 100644 index 0000000..d9d8fa8 --- /dev/null +++ b/src/lib/excerpt.ts @@ -0,0 +1,63 @@ +const ENTITIES: Record = { 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(//).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(/

([\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,;:.–—-]+$/, '')}…`; +} diff --git a/src/lib/rehype-article.mjs b/src/lib/rehype-article.mjs new file mode 100644 index 0000000..9046c27 --- /dev/null +++ b/src/lib/rehype-article.mjs @@ -0,0 +1,178 @@ +/** + * Shape a post's HTML for the article page's styles (src/styles/prose.css). It runs after rehype-raw, so the HTML + * written in the Markdown is made of elements too. + * + * - An image or a video alone in its paragraph becomes a figure, on a panel. The caption is the image's title, or its + * alt text when that is a phrase: an alt text without a space, such as `cost-of-rework`, is a file name. + * - YouTube embeds load from youtube-nocookie.com, lazily, in a 16:9 figure. + * - Tables get a wrapper that scrolls sideways when they don't fit. Columns holding only numbers are marked `num`, a row + * whose cells are all bold is marked `highlight`, and a paragraph right after a table that starts with "Table:" + * becomes its caption. + * - A numbered list that doesn't start at 1 keeps its numbers, which the page draws itself. + */ +const NUMBER = /^[-+−]?[$€£]?\d[\d.,:/]*\s?(?:%|[a-zµ]{1,3})?$/i; +const PLACEHOLDER = /^(?:[-–—]|n\/a)$/i; +const CAPTION = /^Table:\s*/; + +const element = (tagName, properties, children) => ({ type: 'element', tagName, properties, children }); +const isElement = (node, ...names) => node?.type === 'element' && (!names.length || names.includes(node.tagName)); +const isBlank = (node) => node.type === 'comment' || (node.type === 'text' && !node.value.trim()); +const content = (node) => (node.children ?? []).filter((child) => !isBlank(child)); +const textOf = (node) => (node.type === 'text' ? node.value : (node.children ?? []).map(textOf).join('')); +const only = (node) => { + const children = content(node); + return children.length === 1 ? children[0] : undefined; +}; +const addClass = (node, name) => { + node.properties.className = [...(node.properties.className ?? []), name]; +}; + +function figure(media, caption) { + const children = [element('div', { className: ['media'] }, [media])]; + if (caption) children.push(element('figcaption', {}, [{ type: 'text', value: caption }])); + return element('figure', {}, children); +} + +/** The figure for a paragraph that holds only an image (linked or not) or a video. */ +function mediaFigure(paragraph) { + const media = only(paragraph); + if (isElement(media, 'video')) return figure(media); + const image = isElement(media, 'a') ? only(media) : media; + if (!isElement(image, 'img')) return undefined; + const { title, alt } = image.properties; + if (typeof title === 'string' && title.trim()) { + delete image.properties.title; + return figure(media, title.trim()); + } + return figure(media, typeof alt === 'string' && /\s/.test(alt.trim()) ? alt.trim() : undefined); +} + +/** The youtube-nocookie.com address of a YouTube embed, keeping its parameters, such as the start time. */ +function noCookie(src) { + if (typeof src !== 'string') return undefined; + let url; + try { + url = new URL(src.startsWith('//') ? `https:${src}` : src); + } catch { + return undefined; + } + if (!/^https?:$/.test(url.protocol) || !/^(?:www\.|m\.)?youtube(?:-nocookie)?\.com$/.test(url.hostname)) return undefined; + const id = url.pathname.match(/^\/embed\/([\w-]+)\/?$/)?.[1]; + return id && `https://www.youtube-nocookie.com/embed/${id}${url.search}${url.hash}`; +} + +/** The youtube-nocookie.com address for an iframe that embeds a YouTube video; undefined for anything else. */ +const youTubeSrc = (node) => (isElement(node, 'iframe') ? noCookie(node.properties.src) : undefined); + +/** Load a YouTube embed from `src`, lazily. In a figure, the iframe's own sizes give way to the figure's 16:9. */ +function lazyNoCookie(iframe, src, inFigure) { + const { width, height, style, frameBorder, ...rest } = iframe.properties; + iframe.properties = { + ...(inFigure ? rest : iframe.properties), + src, + loading: 'lazy', + referrerPolicy: 'strict-origin-when-cross-origin', + }; + return iframe; +} + +function embedFigure(iframe) { + const result = figure(iframe); + addClass(result.children[0], 'embed'); + return result; +} + +function shapeTable(table, captionParagraph) { + const rows = []; + (function collect(node) { + for (const child of node.children ?? []) { + if (isElement(child, 'tr')) rows.push({ row: child, head: node.tagName === 'thead' }); + else if (isElement(child, 'thead', 'tbody', 'tfoot')) collect(child); + } + })(table); + const cells = (row) => row.children.filter((child) => isElement(child, 'th', 'td')); + const body = rows.filter(({ head }) => !head).map(({ row }) => row); + const columns = Math.max(0, ...rows.map(({ row }) => cells(row).length)); + for (let column = 0; column < columns; column++) { + const values = body.map((row) => (cells(row)[column] ? textOf(cells(row)[column]).trim() : '')); + const numeric = values.some((value) => NUMBER.test(value)) + && values.every((value) => !value || NUMBER.test(value) || PLACEHOLDER.test(value)); + if (!numeric) continue; + for (const { row } of rows) { + const cell = cells(row)[column]; + if (cell) addClass(cell, 'num'); + } + } + for (const row of body) { + const filled = cells(row).filter((cell) => content(cell).length); + if (filled.length && filled.every((cell) => isElement(only(cell), 'strong'))) addClass(row, 'highlight'); + } + if (captionParagraph) { + const [first, ...rest] = captionParagraph.children; + const children = first.value.replace(CAPTION, '') ? [{ ...first, value: first.value.replace(CAPTION, '') }, ...rest] : rest; + table.children.unshift(element('caption', {}, children)); + } + return element('div', { className: ['table'] }, [table]); +} + +function isCaption(node) { + const first = node?.children?.[0]; + return isElement(node, 'p') && first?.type === 'text' && CAPTION.test(first.value) + && Boolean(textOf(node).replace(CAPTION, '').trim()); +} + +function shape(parent) { + const children = parent.children ?? []; + // Whether a figure can go here: not in a paragraph, a link or another figure. + const holdsBlocks = parent.type === 'root' + || isElement(parent, 'div', 'li', 'blockquote', 'section', 'article', 'aside', 'details', 'td', 'th'); + for (let index = 0; index < children.length; index++) { + const node = children[index]; + if (!isElement(node)) continue; + + const media = holdsBlocks && isElement(node, 'p') && mediaFigure(node); + if (media) { + children[index] = media; + continue; + } + // A video written as an HTML block of its own, outside any paragraph. + if (isElement(node, 'video') && holdsBlocks) { + children[index] = figure(node); + continue; + } + // An embed alone in its own block, often a

that gave it a 16:9 box with inline styles. + const embed = holdsBlocks && isElement(node, 'div', 'p') ? only(node) : undefined; + const embedSrc = youTubeSrc(embed); + if (embedSrc) { + children[index] = embedFigure(lazyNoCookie(embed, embedSrc, true)); + continue; + } + // An embed among other content: a figure where a block can go, or else left in place with its own sizes. + const src = youTubeSrc(node); + if (src) { + lazyNoCookie(node, src, holdsBlocks); + if (holdsBlocks) children[index] = embedFigure(node); + continue; + } + if (isElement(node, 'table')) { + let next = index + 1; + while (next < children.length && isBlank(children[next])) next++; + const caption = isCaption(children[next]) ? children[next] : undefined; + if (caption) children.splice(index + 1, next - index); + shape(node); // what the cells hold + children[index] = shapeTable(node, caption); + continue; + } + const { start, style } = node.properties; + if (isElement(node, 'ol') && typeof start === 'number' && start !== 1) { + node.properties.style = [style, `counter-reset: n ${start - 1}`].filter(Boolean).join('; '); + } + shape(node); + } +} + +// Its options go unused: astro.config.mjs passes a hash of the Markdown plugins there, so that Astro renders the posts +// again when they change. +export default function rehypeArticle() { + return (tree) => shape(tree); +} diff --git a/src/lib/remark-heading-levels.mjs b/src/lib/remark-heading-levels.mjs new file mode 100644 index 0000000..f04b1f1 --- /dev/null +++ b/src/lib/remark-heading-levels.mjs @@ -0,0 +1,23 @@ +/** + * Give a post's headings a hierarchy under the page title, the page's only h1: the highest level the text uses becomes + * h2 and the others keep their distance to it. A post with `#` sections gets h2 sections and h3 subsections; one that + * starts at `###` gets them moved up. No heading goes more than one level below the one before it, the title included, + * so a `##` before the first `#` section becomes h2 rather than an h3 straight under the title. Runs after + * remark-drop-title, which removes the `# Title` line. + */ +export default function remarkHeadingLevels() { + return (tree) => { + const headings = []; + (function collect(node) { + if (node.type === 'heading') headings.push(node); + node.children?.forEach(collect); + })(tree); + if (!headings.length) return; + const shift = 2 - Math.min(...headings.map((heading) => heading.depth)); + let previous = 1; // the page title + for (const heading of headings) { + heading.depth = Math.min(6, heading.depth + shift, previous + 1); + previous = heading.depth; + } + }; +} diff --git a/src/pages/articles/[slug].astro b/src/pages/articles/[slug].astro index 7d71e55..6522f9a 100644 --- a/src/pages/articles/[slug].astro +++ b/src/pages/articles/[slug].astro @@ -1,22 +1,58 @@ --- +// An article or an activity log, built from the Paper design "V2 · Article — Serif text" with its column widened to +// 720px: the back link, the meta line, the title and the text, then the project the article belongs to and the +// previous and next posts. The text's styles are in src/styles/prose.css. import type { GetStaticPaths } from 'astro'; import { getCollection, render, type CollectionEntry } from 'astro:content'; import Layout from '../../layouts/Layout.astro'; import JsonLd from '../../components/JsonLd.astro'; -import { postShare } from '../../lib/share'; +import ProjectPanel from '../../components/ProjectPanel.astro'; +import PrevNext from '../../components/PrevNext.astro'; +import { projects, projectOf } from '../../data/projects'; +import { isDailyLog } from '../../lib/posts.mjs'; +import { newestFirst } from '../../lib/sort-posts'; +import { articleMeta, postShare } from '../../lib/share'; +import { excerpt } from '../../lib/excerpt'; +import '../../styles/prose.css'; + +type Post = CollectionEntry<'writing'>; + +interface Props { + article: Post; + previous?: Post; + next?: Post; +} export const getStaticPaths: GetStaticPaths = async () => { - const articles = await getCollection('writing'); - return articles.map((article) => ({ - params: { slug: article.id }, - props: article, - })); -} + const posts = await getCollection('writing'); + const ids = new Set(posts.map((post) => post.id)); + for (const project of projects) { + for (const id of project.articles) { + if (!ids.has(id)) console.warn(`src/data/projects.ts: ${project.name} lists an article that doesn't exist: ${id}`); + } + } -const article = Astro.props as CollectionEntry<'writing'>; + // Previous and Next go through the posts newest first: Previous is the older post, Next the newer one. Articles link + // to articles, as the redesigned home's Writing list (#31) holds only articles; activity logs link to activity logs. + return [posts.filter((post) => !isDailyLog(post)), posts.filter(isDailyLog)].flatMap((group) => { + const sorted = group.sort(newestFirst); + return sorted.map((article, index) => ({ + params: { slug: article.id }, + props: { article, previous: sorted[index + 1], next: sorted[index - 1] }, + })); + }); +}; + +const { article, previous, next } = Astro.props; const { Content } = await render(article); const share = postShare(article); +const project = projectOf(article.id); +// The date and reading time as the share image prints them +const { date: dateText, minutes } = articleMeta(article); +const description = article.data.description || excerpt(article.rendered?.html ?? '') || article.data.title; +const date = article.data.date; + // JSON-LD structured data for BlogPosting const canonical = new URL(Astro.url.pathname, Astro.site).toString(); const author = { @@ -28,12 +64,12 @@ const articleSchema = { "@context": "https://schema.org", "@type": "BlogPosting", "headline": article.data.title, - "description": article.data.description || article.data.title, + "description": description, "image": new URL(share.path, Astro.site).toString(), "author": author, "publisher": author, - "datePublished": article.data.date.toISOString(), - "dateModified": (article.data.updated ?? article.data.date).toISOString(), + "datePublished": date.toISOString(), + "dateModified": (article.data.updated ?? date).toISOString(), "mainEntityOfPage": { "@type": "WebPage", "@id": canonical }, "url": canonical }; @@ -41,158 +77,93 @@ const articleSchema = { -
+
+ ← All writing
-
+
+

+ + · {minutes} min read{project && <> · {project.name}} +

{article.data.title}

-
-
+
+
+ {project && } + +
diff --git a/src/styles/legacy.css b/src/styles/legacy.css index 5201089..259e424 100644 --- a/src/styles/legacy.css +++ b/src/styles/legacy.css @@ -1,6 +1,6 @@ -/* The content styles of the pages from before the redesign, kept until each page is rebuilt: the home (#31), the - article page (#32) and About (#33). They apply inside an element with the legacy class, which those pages give their -
. Rebuilding a page drops the class, and the last one deletes this file. +/* The content styles of the pages from before the redesign, kept until each page is rebuilt: the home (#31) and About + (#33); the article page (#32) has been. They apply inside an element with the legacy class, which those pages give + their
. Rebuilding a page drops the class, and the last one deletes this file. Only the fonts, the weights and the colours changed with the redesign's foundations (#29). :where() gives each rule the specificity it had as a global rule, so the pages' own styles still win where they did. */ diff --git a/src/styles/prose.css b/src/styles/prose.css new file mode 100644 index 0000000..175371a --- /dev/null +++ b/src/styles/prose.css @@ -0,0 +1,266 @@ +/* The text of a post, from its Markdown: Newsreader 20px/32px (18px/29px on phones), with the headings, the code, the + numbers of lists and tables and the captions in mono. src/lib/rehype-article.mjs shapes the HTML these rules expect: + figures, embeds, tables. The selectors use :where() so that each rule weighs only as much as .prose. */ +.prose { + font-family: var(--serif); + font-size: 20px; + line-height: 32px; + overflow-wrap: break-word; +} + +.prose :where(p, ul, ol, li, figure, blockquote, pre, h2, h3, h4, h5, h6, hr, table, .table) { + margin: 0; +} + +/* The space above each block, which depends on the block before it. */ +.prose :where(* + p, * + pre, * + blockquote) { margin-top: 20px; } +.prose :where(* + ul, * + ol) { margin-top: 10px; } +.prose :where(* + figure) { margin-top: 24px; } +.prose :where(* + .table) { margin-top: 36px; } +.prose :where(* + h2, * + hr) { margin-top: 48px; } +.prose :where(* + h3) { margin-top: 36px; } +.prose :where(* + h4, * + h5, * + h6) { margin-top: 28px; } +.prose :where(h2, h3, h4, h5, h6) + :where(p, pre, blockquote) { margin-top: 12px; } +.prose :where(figure, .table) + :where(p, ul, ol, pre, blockquote) { margin-top: 36px; } +.prose :where(.table) + :where(h2) { margin-top: 56px; } +.prose :where(hr) + * { margin-top: 48px; } + +/* Headings */ +.prose :where(h2, h3, h4, h5, h6) { + font-family: var(--mono); + font-weight: 400; +} + +.prose :where(h2) { + font-size: 22px; + line-height: 30px; + letter-spacing: -0.04em; +} + +.prose :where(h3) { + font-size: 18px; + line-height: 26px; + letter-spacing: -0.03em; +} + +.prose :where(h4, h5, h6) { + font-size: 16px; + line-height: 24px; + letter-spacing: -0.02em; +} + +/* Links and code */ +.prose :where(a) { + color: var(--accent); + text-decoration: underline 1px; + text-underline-position: from-font; +} + +.prose :where(code) { + font-family: var(--mono); + font-size: 16px; +} + +.prose :where(h2, h3, h4, h5, h6, th, td, pre, caption, figcaption) code { + font-size: inherit; +} + +.prose :where(pre) { + overflow-x: auto; + padding: 16px 20px; + border: 1px solid var(--line); + font-family: var(--mono); + font-size: 14px; + line-height: 22px; +} + +/* Lists: muted bullets or mono numbers, hanging 22px or 28px. */ +.prose :where(ul, ol) { + display: grid; + grid-template-columns: minmax(0, 1fr); /* so that a long link wraps instead of widening the list */ + gap: 4px; + padding: 0; + list-style: none; +} + +.prose :where(ol) { + gap: 6px; + counter-reset: n; +} + +.prose :where(li) { + position: relative; + padding-left: 22px; +} + +.prose :where(ol > li) { + padding-left: 28px; + counter-increment: n; +} + +.prose :where(li)::before { + position: absolute; + left: 0; + content: '•'; + color: var(--muted); +} + +.prose :where(ol > li)::before { + content: counter(n) '.'; + font-family: var(--mono); + font-size: 13px; +} + +.prose :where(li > ul, li > ol) { + margin-top: 4px; +} + +.prose :where(blockquote) { + padding: 12px 20px; + border-left: 2px solid var(--accent); + background: var(--tint); +} + +.prose :where(hr) { + height: 0; + border: 0; + border-top: 1px solid var(--line); +} + +.prose :where(img, video) { + max-width: 100%; + height: auto; +} + +.prose :where(iframe) { + max-width: 100%; +} + +/* Figures: the image, the video or the embed on a grey panel, with a caption under it. */ +.prose :where(figure > .media) { + padding: 16px; + background: var(--panel); +} + +.prose :where(figure img, figure video) { + display: block; + max-width: 100%; + height: auto; + margin-inline: auto; + border: 1px solid var(--line); + background: var(--page); +} + +.prose :where(figure video) { + width: 100%; +} + +.prose :where(.embed iframe) { + display: block; + width: 100%; + height: auto; + aspect-ratio: 16 / 9; + border: 0; +} + +.prose :where(figcaption, caption) { + padding-top: 12px; + font-family: var(--mono); + font-size: 12px; + line-height: 19px; + text-align: left; + color: var(--muted); +} + +/* Tables: the header in small muted mono over an ink line, rows over a light line, numbers right-aligned in 104px mono + columns, and a highlighted row on the tint with an accent line on its left. */ +.prose :where(.table) { + overflow-x: auto; +} + +.prose :where(table) { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.prose :where(caption) { + caption-side: bottom; +} + +.prose :where(th, td) { + padding: 10px 0 10px 16px; + border-bottom: 1px solid var(--line); + vertical-align: baseline; + font-family: var(--serif); + font-size: 16px; + line-height: 21px; +} + +.prose :where(th, td):first-child { + padding-left: 12px; + border-left: 2px solid transparent; +} + +.prose :where(th, td):last-child { + padding-right: 12px; +} + +.prose :where(th) { + padding-top: 0; + padding-bottom: 9px; + border-bottom-color: var(--ink); + vertical-align: bottom; + text-align: left; + font-family: var(--mono); + font-weight: 400; + font-size: 11px; + line-height: 16px; + letter-spacing: 0.03em; + color: var(--muted); +} + +.prose :where(th, td).num { + box-sizing: content-box; + width: 104px; + padding-left: 0; + text-align: right; +} + +.prose :where(td).num { + white-space: nowrap; + font-family: var(--mono); + font-size: 13px; +} + +.prose :where(tr.highlight > td) { + background: var(--tint); +} + +.prose :where(tr.highlight > td):first-child { + border-left-color: var(--accent); +} + +/* The highlight replaces the bold that marks the row in the Markdown. */ +.prose :where(tr.highlight strong) { + font-weight: inherit; +} + +@media (max-width: 900px) { + .prose { + font-size: 18px; + line-height: 29px; + } + + .prose :where(td) { + font-size: 15px; + } + + .prose :where(th, td).num { + width: 64px; + } + + .prose :where(td).num { + font-size: 12px; + } +}