Compare commits

..

10 commits

24 changed files with 171 additions and 215 deletions

View file

@ -1,25 +0,0 @@
name: Validate content
on:
push:
paths:
- 'www/content/**'
- 'www/public/*.txt'
- 'www/public/config.yaml'
pull_request:
paths:
- 'www/content/**'
- 'www/public/*.txt'
- 'www/public/config.yaml'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm validate:www

View file

@ -6,7 +6,6 @@
"dev:penfield": "pnpm --filter @ily/penfield dev",
"dev:www": "pnpm --filter @ily/www dev",
"build:penfield": "pnpm --filter @ily/penfield build",
"build:www": "pnpm --filter @ily/www build",
"validate:www": "pnpm --filter @ily/www validate"
"build:www": "pnpm --filter @ily/www build"
}
}

View file

@ -1,7 +1,6 @@
---
title: hello
date: 2023-02-26
pinned: true
date: 2026-02-26
---
i've always had some sort of homepage. it was originally on bebo, then that died and i didn't ever get into other social media, so i made a website

View file

@ -5,8 +5,7 @@
"dev": "astro dev --port 4322",
"build": "astro build --remote && node scripts/generate-stats.js",
"preview": "astro preview",
"serve": "pnpm build && npx serve .vercel/output/static -l 4322",
"validate": "node scripts/validate-content.js"
"serve": "pnpm build && npx serve .vercel/output/static -l 4322"
},
"dependencies": {
"@astrojs/db": "^0.19.0",

View file

@ -1,3 +1,4 @@
2026-03-27 - narrower layout (34rem), single-column entries, serif font, smaller muted text
2026-03-26 - inline section labels, compact layout
2026-02-07 - related posts !
2026-01-31 - text files now live at cleaner URLs (/*.txt instead of /txt/*.txt)

View file

@ -1,4 +1,3 @@
pinned: []
descriptions:
cv.txt: curriculum vitae
now.txt: what i'm doing now

View file

@ -4,8 +4,7 @@
var just = p.get('just');
if (just && /^[a-z0-9-]+$/.test(just)) {
document.documentElement.dataset.just = just;
var css = 'section[data-section]:not([data-section="' + just + '"]){display:none}'
+ ' section[data-section="' + just + '"] .section-label{pointer-events:none;text-decoration:none;color:inherit}';
var css = 'section[data-section]:not([data-section="' + just + '"]){display:none}';
document.head.appendChild(Object.assign(document.createElement('style'), { textContent: css }));
}
@ -30,7 +29,10 @@
if (has) {
document.documentElement.dataset.has = has;
has = has.toLowerCase();
document.addEventListener('DOMContentLoaded', function() {
}
document.addEventListener('DOMContentLoaded', function() {
if (has) {
document.querySelectorAll('section[data-section] .entry').forEach(function(entry) {
if (entry.textContent.toLowerCase().indexOf(has) === -1) {
entry.style.display = 'none';
@ -41,6 +43,22 @@
entry.style.display = 'none';
}
});
}
document.querySelectorAll('.section-label').forEach(function(a) {
var link = new URLSearchParams(a.search);
p.forEach(function(v, k) { if (!link.has(k)) link.set(k, v); });
a.href = '?' + link.toString();
});
}
var find = document.getElementById('find');
if (find) find.addEventListener('click', function(e) {
e.preventDefault();
var term = prompt('find:');
if (!term) return;
var q = new URLSearchParams(location.search);
q.set('has', term);
location.search = q.toString();
});
});
}();

View file

@ -1,43 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
const root = path.resolve(import.meta.dirname, '..');
const contentDir = path.join(root, 'content');
const publicDir = path.join(root, 'public');
const errors = [];
const mdFiles = fs.readdirSync(contentDir).filter(f => f.endsWith('.md'));
for (const file of mdFiles) {
const content = fs.readFileSync(path.join(contentDir, file), 'utf8');
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) {
errors.push(`${file}: missing frontmatter`);
continue;
}
const frontmatter = match[1];
if (!/^date:\s*.+/m.test(frontmatter)) {
errors.push(`${file}: missing required 'date' field`);
}
}
const configPath = path.join(publicDir, 'config.yaml');
const config = fs.existsSync(configPath)
? yaml.load(fs.readFileSync(configPath, 'utf8'))
: {};
const configDates = config.dates || {};
const txtFiles = fs.readdirSync(publicDir).filter(f => f.endsWith('.txt'));
for (const file of txtFiles) {
if (!configDates[file]) {
errors.push(`${file}: missing date in config.yaml`);
}
}
if (errors.length) {
console.error('Content validation failed:\n');
for (const err of errors) console.error(` - ${err}`);
console.error('');
process.exit(1);
} else {
console.log(`Validated ${mdFiles.length} posts and ${txtFiles.length} txt files.`);
}

View file

@ -3,13 +3,12 @@ import { glob, file } from 'astro/loaders';
import { z } from 'astro/zod';
import yaml from 'js-yaml';
const md = defineCollection({
const posts = defineCollection({
loader: glob({ pattern: '**/*.md', base: './content' }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
updated: z.coerce.date().optional(),
pinned: z.boolean().optional(),
category: z.string().optional(),
related: z.array(z.string()).optional(),
})
@ -29,4 +28,4 @@ const bookmarks = defineCollection({
})
});
export const collections = { md, bookmarks };
export const collections = { posts, bookmarks };

View file

@ -9,7 +9,7 @@ interface Props {
urls?: string[];
}
const { title, description = 'personal website of lewis m.w.', showHeader = true, isHome = false, urls = [] } = Astro.props;
const { title, description = 'personal website of ' + title, showHeader = true, isHome = false, urls = [] } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
@ -28,7 +28,18 @@ const { title, description = 'personal website of lewis m.w.', showHeader = true
<body>
{showHeader && (
<header>
<pre>{isHome ? <Fragment><span class="home-name">lewis m.w.</span><a class="home-name-link" href="/">lewis m.w.</a></Fragment> : <a href="/">lewis m.w.</a>} <a href="mailto:lewis@wynne.rs">mail</a> <a href="https://github.com/llywelwyn">gh</a> <a href="/feed.xml">rss</a> <a href="/sitemap.txt">sitemap</a> <a href="/?do=random">random</a> <a href="/?do=newest">newest</a></pre>
<span class="header-name">
<a href="/">{isHome ? title : 'lewis m.w.'}</a>
</span>
<span class="header-links">
<a href="mailto:lewis@wynne.rs">mail</a>
<a href="https://github.com/llywelwyn">gh</a>
<a href="/feed.xml">rss</a>
<a href="/sitemap.txt">sitemap</a>
<a href="/?do=random">random</a>
<a href="/?do=newest">newest</a>
<a id="find" href="/?has=">find</a>
</span>
</header>
)}
<slot />

View file

@ -1,5 +1,3 @@
import { isAdmin } from './auth';
export function jsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
@ -10,10 +8,3 @@ export function jsonResponse(data: unknown, status = 200): Response {
export function errorResponse(message: string, status: number): Response {
return jsonResponse({ error: message }, status);
}
export function requireAdmin(session: { user?: { id?: string } } | null): Response | null {
if (!session?.user?.id || !isAdmin(session.user.id)) {
return errorResponse('Unauthorized', 403);
}
return null;
}

View file

@ -1,29 +1,26 @@
import { getSession } from 'auth-astro/server';
type Session = { user?: { id?: string; name?: string | null } };
export type Session = { user?: { id?: string; name?: string | null } };
export function isAdmin(userId: string | undefined): boolean {
return userId === import.meta.env.ADMIN_GITHUB_ID;
}
export type AuthResult =
| { status: 'admin'; session: Session }
| { status: 'unauthenticated' }
| { status: 'forbidden' }
| { status: 'error' };
export async function requireAdminSession(request: Request): Promise<
| { session: Session; error: null }
| { session: null; error: Response | null }
> {
export async function getAdminSession(request: Request): Promise<AuthResult> {
let session: Session | null;
try {
session = await getSession(request);
} catch {
return { session: null, error: new Response('Auth not configured', { status: 500 }) };
return { status: 'error' };
}
if (!session) {
return { session: null, error: null };
if (!session) return { status: 'unauthenticated' };
if (session.user?.id !== import.meta.env.ADMIN_GITHUB_ID) {
return { status: 'forbidden' };
}
if (!isAdmin(session.user?.id)) {
return { session: null, error: new Response('Forbidden', { status: 403 }) };
}
return { session, error: null };
return { status: 'admin', session };
}

View file

@ -6,7 +6,7 @@ export const SUBDOMAINS = [
];
export const SECTIONS = {
plaintext: 'plaintext',
files: 'files',
bookmarks: 'bookmarks',
guestbook: 'guestbook',
} as const;

View file

@ -26,25 +26,45 @@ export function formatDate(date: Date): string {
return `${d}/${m}/${y}`;
}
export function wordCount(markdown: string | undefined): string {
if (!markdown) return '';
const words = markdown
.replace(/^---[\s\S]*?---/m, '')
.replace(/^#+\s+.*$/gm, '')
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[*_~`]/g, '')
.replace(/:[a-z]+\[([^\]]*)\]/g, '$1')
.trim()
.split(/\s+/)
.filter(Boolean).length;
if (words < 100) return `${words} words`;
const mins = Math.ceil(words / 200);
return `${mins} min`;
}
export function extractDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return '';
}
}
export function formatListItem(
date: Date,
url: string,
title: string,
options?: { pinned?: boolean }
options?: { suffix?: string }
): string {
const pinnedBadge = options?.pinned ? ' [pinned]' : '';
return `<span class="list-meta"><span class="muted">${formatDate(date)}</span></span><span class="entry-content"><a href="${url}" title="${title}">${title}</a>${pinnedBadge}</span>`;
const suffixHtml = options?.suffix ? `<span class="entry-suffix muted">${options.suffix}</span>` : '';
return `<span class="list-meta"><span class="muted">${formatDate(date)}</span></span><span class="entry-content"><a href="${url}" title="${title}">${title}</a>${suffixHtml}</span>`;
}
interface Sortable {
date: Date;
pinned?: boolean;
}
export function sortByPinnedThenDate<T extends Sortable>(items: T[]): T[] {
return items.slice().sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return b.date.getTime() - a.date.getTime();
});
export function sortEntries<T>(items: T[], key?: (item: T) => Sortable): T[] {
const get = key ?? (item => item as unknown as Sortable);
return items.slice().sort((a, b) => get(b).date.getTime() - get(a).date.getTime());
}

View file

@ -1,22 +1,14 @@
import type { CollectionEntry } from 'astro:content';
import { DEFAULT_CATEGORY } from './consts';
import { sortEntries } from './format';
type Post = CollectionEntry<'md'>;
export type Post = CollectionEntry<'posts'> & { body?: string };
export function getSlug(postId: string): string {
const parts = postId.split('/');
return parts[parts.length - 1];
}
function sortPosts(posts: Post[], { alphabetically = false } = {}): Post[] {
return posts.slice().sort((a, b) => {
if (a.data.pinned && !b.data.pinned) return -1;
if (!a.data.pinned && b.data.pinned) return 1;
if (alphabetically) return a.data.title.localeCompare(b.data.title);
return b.data.date.getTime() - a.data.date.getTime();
});
}
export function resolveRelatedPosts<T extends { id: string }>(
slugs: string[],
allPosts: T[],
@ -25,7 +17,7 @@ export function resolveRelatedPosts<T extends { id: string }>(
return slugs.flatMap(s => bySlug.get(s) ?? []);
}
export function organizePostsByCategory(posts: Post[], { sortAlphabetically = false } = {}): {
export function organizePostsByCategory(posts: Post[]): {
grouped: Record<string, Post[]>;
categories: string[];
} {
@ -43,7 +35,7 @@ export function organizePostsByCategory(posts: Post[], { sortAlphabetically = fa
});
for (const category of categories) {
grouped[category] = sortPosts(grouped[category], { alphabetically: sortAlphabetically });
grouped[category] = sortEntries(grouped[category], p => p.data);
}
return { grouped, categories };

View file

@ -1,17 +1,15 @@
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { sortByPinnedThenDate } from './format';
import { sortEntries } from './format';
export interface TxtFile {
name: string;
date: Date;
pinned: boolean;
description?: string;
}
export interface TxtConfig {
pinned?: string[];
descriptions?: Record<string, string>;
dates?: Record<string, string>;
}
@ -32,7 +30,6 @@ export function getTxtFiles(): TxtFile[] {
if (!fs.existsSync(txtDir)) return [];
const config = loadTxtConfig();
const pinnedSet = new Set(config.pinned || []);
const descriptions = config.descriptions || {};
const dates = config.dates || {};
@ -41,9 +38,8 @@ export function getTxtFiles(): TxtFile[] {
.map(name => ({
name,
date: dates[name] ? new Date(dates[name]) : new Date(0),
pinned: pinnedSet.has(name),
description: descriptions[name],
}));
return sortByPinnedThenDate(files);
return sortEntries(files);
}

View file

@ -1,11 +1,11 @@
---
import { getCollection, render } from 'astro:content';
import Layout from '../layouts/Layout.astro';
import { formatDate, formatListItem, excerpt } from '../lib/format';
import { getSlug, resolveRelatedPosts } from '../lib/md';
import { formatDate, formatListItem, excerpt, wordCount } from '../lib/format';
import { getSlug, resolveRelatedPosts, type Post } from '../lib/posts';
export async function getStaticPaths() {
const allPosts = await getCollection('md');
const allPosts = await getCollection('posts');
return allPosts.map(post => ({
params: { slug: getSlug(post.id) },
props: { post, allPosts }
@ -15,13 +15,13 @@ export async function getStaticPaths() {
const { post, allPosts } = Astro.props;
const { Content } = await render(post);
const related = post.data.related ? resolveRelatedPosts(post.data.related, allPosts) : [];
const description = excerpt((post as any).body) || undefined;
const description = excerpt((post as Post).body) || undefined;
---
<Layout title={`${post.data.title} - lewis m.w.`} description={description}>
<article>
<h1>{post.data.title}</h1>
<p class="muted" style="margin-top: 0;">{formatDate(post.data.date)}{post.data.updated && ` (updated ${formatDate(post.data.updated)})`}</p>
<p class="muted" style="margin-top: 0;">{formatDate(post.data.date)}{post.data.updated && ` (updated ${formatDate(post.data.updated)})`} · {wordCount((post as Post).body)}{post.data.category && ` · ${post.data.category}`}</p>
<Content />
</article>
{related.length > 0 && (

View file

@ -2,13 +2,15 @@
export const prerender = false;
import { getPendingEntries, type GuestbookEntry } from '../lib/db';
import { requireAdminSession } from '../lib/auth';
import { getAdminSession } from '../lib/auth';
import Layout from '../layouts/Layout.astro';
import { formatDate } from '../lib/format';
const { session, error } = await requireAdminSession(Astro.request);
if (error) return error;
if (!session) return Astro.redirect('/api/auth/signin');
const auth = await getAdminSession(Astro.request);
if (auth.status === 'error') return new Response('Auth not configured', { status: 500 });
if (auth.status === 'unauthenticated') return Astro.redirect('/api/auth/signin');
if (auth.status !== 'admin') return new Response('Forbidden', { status: 403 });
const { session } = auth;
let entries: GuestbookEntry[] = [];
try {

View file

@ -1,23 +1,18 @@
import type { APIRoute } from 'astro';
import { getSession } from 'auth-astro/server';
import { jsonResponse, errorResponse, requireAdmin } from '../../lib/api';
import { jsonResponse, errorResponse } from '../../lib/api';
import { getAdminSession } from '../../lib/auth';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const session = await getSession(request);
const authError = requireAdmin(session);
if (authError) return authError;
const auth = await getAdminSession(request);
if (auth.status !== 'admin') return errorResponse('Unauthorized', 403);
const hookUrl = import.meta.env.VERCEL_DEPLOY_HOOK;
if (!hookUrl) {
return errorResponse('Deploy hook not configured', 500);
}
if (!hookUrl) return errorResponse('Deploy hook not configured', 500);
const res = await fetch(hookUrl, { method: 'POST' });
if (!res.ok) {
return errorResponse('Failed to trigger deploy', 502);
}
if (!res.ok) return errorResponse('Failed to trigger deploy', 502);
return jsonResponse({ success: true });
};

View file

@ -1,33 +1,27 @@
import type { APIRoute } from 'astro';
import { getSession } from 'auth-astro/server';
import { approveEntry, deleteEntry } from '../../../lib/db';
import { jsonResponse, errorResponse, requireAdmin } from '../../../lib/api';
import { jsonResponse, errorResponse } from '../../../lib/api';
import { getAdminSession } from '../../../lib/auth';
export const prerender = false;
export const PATCH: APIRoute = async ({ params, request }) => {
const session = await getSession(request);
const authError = requireAdmin(session);
if (authError) return authError;
const auth = await getAdminSession(request);
if (auth.status !== 'admin') return errorResponse('Unauthorized', 403);
const id = parseInt(params.id!, 10);
if (isNaN(id)) {
return errorResponse('Invalid ID', 400);
}
if (isNaN(id)) return errorResponse('Invalid ID', 400);
await approveEntry(id);
return jsonResponse({ success: true });
};
export const DELETE: APIRoute = async ({ params, request }) => {
const session = await getSession(request);
const authError = requireAdmin(session);
if (authError) return authError;
const auth = await getAdminSession(request);
if (auth.status !== 'admin') return errorResponse('Unauthorized', 403);
const id = parseInt(params.id!, 10);
if (isNaN(id)) {
return errorResponse('Invalid ID', 400);
}
if (isNaN(id)) return errorResponse('Invalid ID', 400);
await deleteEntry(id);
return jsonResponse({ success: true });

View file

@ -1,12 +1,12 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
import { getSlug } from '../lib/md';
import { getSlug, type Post } from '../lib/posts';
import { getTxtFiles } from '../lib/txt';
import { excerpt } from '../lib/format';
export async function GET(context: APIContext) {
const posts = await getCollection('md');
const posts = await getCollection('posts');
const txtFiles = getTxtFiles();
const items = [
@ -14,7 +14,7 @@ export async function GET(context: APIContext) {
title: post.data.title,
pubDate: post.data.date,
link: `/${getSlug(post.id)}`,
description: excerpt((post as any).body) || post.data.title,
description: excerpt((post as Post).body) || post.data.title,
})),
...txtFiles.map(txt => ({
title: txt.name,

View file

@ -2,12 +2,12 @@
import { getCollection } from 'astro:content';
import Layout from '../layouts/Layout.astro';
import { getApprovedEntries, type GuestbookEntry } from '../lib/db';
import { formatDate, formatListItem, escapeHtml } from '../lib/format';
import { organizePostsByCategory, getSlug } from '../lib/md';
import { formatDate, formatListItem, extractDomain, wordCount, escapeHtml } from '../lib/format';
import { organizePostsByCategory, getSlug } from '../lib/posts';
import { getTxtFiles } from '../lib/txt';
import { DEFAULT_CATEGORY, SECTIONS, SUBDOMAINS } from '../lib/consts';
const posts = await getCollection('md');
const posts = await getCollection('posts');
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
const bookmarksCollection = await getCollection('bookmarks');
@ -37,24 +37,24 @@ const urls = [
<section data-section={category}>
{!isDefault && <a class="section-label" href={`?just=${category}`}>{category}</a>}
<div class="entry-list" set:html={categoryPosts.map(post =>
`<span class="entry">${formatListItem(post.data.date, `/${getSlug(post.id)}`, post.data.title, { pinned: post.data.pinned })}</span>`
`<span class="entry">${formatListItem(post.data.date, `/${getSlug(post.id)}`, post.data.title, { suffix: wordCount(post.body) })}</span>`
).join('')} />
</section>
);
})}
<section data-section={SECTIONS.plaintext}>
<a class="section-label" href={`?just=${SECTIONS.plaintext}`}>{SECTIONS.plaintext}</a>
<section data-section={SECTIONS.files}>
<a class="section-label" href={`?just=${SECTIONS.files}`}>{SECTIONS.files}</a>
<div class="entry-list" set:html={txtFiles.map(f => {
const name = f.name.replace(/\.txt$/, '');
return `<span class="entry">${formatListItem(f.date, `/${f.name}`, name, { pinned: f.pinned })}</span>`;
return `<span class="entry">${formatListItem(f.date, `/${f.name}`, name, { suffix: f.description })}</span>`;
}).join('')} />
</section>
<section data-section={SECTIONS.bookmarks}>
<a class="section-label" href={`?just=${SECTIONS.bookmarks}`}>{SECTIONS.bookmarks}</a>
<div class="entry-list" set:html={bookmarks.map(b =>
`<span class="entry">${formatListItem(b.data.date, b.data.url, b.data.title)}</span>`
`<span class="entry">${formatListItem(b.data.date, b.data.url, b.data.title, { suffix: extractDomain(b.data.url) })}</span>`
).join('')} />
</section>

View file

@ -1,12 +1,12 @@
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
import { getSlug } from '../lib/md';
import { getSlug } from '../lib/posts';
import { getTxtFiles } from '../lib/txt';
import { SUBDOMAINS } from '../lib/consts';
export async function GET(context: APIContext) {
const site = context.site?.origin ?? 'https://wynne.rs';
const posts = await getCollection('md');
const posts = await getCollection('posts');
const txtFiles = getTxtFiles().map(f => f.name);
const urls = [

View file

@ -1,9 +1,10 @@
body {
box-sizing: border-box;
max-width: 48rem;
max-width: 34rem;
margin: 0 auto;
padding: 1rem;
text-align: justify;
font-family: 'Times New Roman', serif;
}
img {
@ -17,6 +18,7 @@ h1, h2, h3, h4, h5, h6 {
.muted {
color: #888;
font-size: 0.9rem;
}
.left, .right {
@ -24,7 +26,7 @@ h1, h2, h3, h4, h5, h6 {
font-size: 0.9rem;
}
@media (min-width: 63rem) {
@media (min-width: 58rem) {
.left, .right {
display: inline;
position: relative;
@ -78,67 +80,77 @@ section {
margin: 1rem 0;
}
section .section-label {
font-family: monospace;
}
.home-name-link {
display: none;
}
html[data-just] .home-name {
display: none;
}
html[data-just] .home-name-link {
display: inline;
}
html[data-has] .guestbook-form {
display: none;
}
header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 2rem;
}
.header-name {
white-space: nowrap;
}
.header-links {
text-align: right;
}
section pre {
margin: 0;
}
.entry-list {
columns: 2 24ch;
column-gap: 3ch;
font-family: monospace;
margin: 0;
}
.entry {
display: grid;
grid-template-columns: 10ch 1fr;
grid-template-columns: 4rem 1fr;
align-items: baseline;
break-inside: avoid;
}
.entry-content {
display: flex;
overflow: hidden;
white-space: nowrap;
}
.entry-content > a {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.entry-suffix {
flex: 1 10000 0%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
padding-left: 0.5rem;
}
.guestbook-entries {
font-family: monospace;
white-space: pre;
margin: 0;
}
.guestbook-entry {
display: grid;
grid-template-columns: 10ch 1fr;
}
.guestbook-entry > span:last-child {
white-space: normal;
grid-template-columns: 4rem 1fr;
align-items: baseline;
break-inside: avoid;
}
.guestbook-form {
margin-top: 0.5rem;
margin-left: 10ch;
font-family: monospace;
margin-left: 4rem;
}
html[data-compact] .list-meta {