= { 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
-
+
-
+
+
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;
+ }
+}