diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 830164a..f6a319e 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,12 +1,17 @@ import { building } from '$app/environment'; import { type Handle } from '@sveltejs/kit'; -// Resolve the %lang% placeholder in app.html. Only blog/project detail pages are -// bilingual (Korean default); the rest of the site is English chrome. The client -// reconciles this with the actually-shown language after hydration. +// Resolve the %lang% placeholder in app.html. Only the bilingual detail pages — +// blog posts, projects and books — have a Korean default; the rest of the site +// is English chrome. The client reconciles this with the actually-shown language +// after hydration, but crawlers and screen readers only ever see this one, so a +// route missing from this list serves Korean prose declared as English. export const handle: Handle = async ({ event, resolve }) => { const { pathname } = event.url; - const isContent = pathname.startsWith('/blog/') || pathname.startsWith('/projects/'); + const isContent = + pathname.startsWith('/blog/') || + pathname.startsWith('/projects/') || + pathname.startsWith('/books/'); let lang = 'en'; if (isContent) { diff --git a/src/lib/NavBar.svelte b/src/lib/NavBar.svelte index 898092e..b5f4a6f 100644 --- a/src/lib/NavBar.svelte +++ b/src/lib/NavBar.svelte @@ -15,6 +15,7 @@ const navItems = [ { label: 'projects', href: '/projects' }, { label: 'blog', href: '/blog' }, + { label: 'books', href: '/books' }, { label: 'now', href: '/now' } ]; diff --git a/src/lib/books/BookCover.svelte b/src/lib/books/BookCover.svelte new file mode 100644 index 0000000..38fff30 --- /dev/null +++ b/src/lib/books/BookCover.svelte @@ -0,0 +1,291 @@ + + +
+
+
+ + + + + {book.title} + {#if author}{author}{/if} + +
+
+ {#if book.cover} + + + {:else} +
+ {book.title} + {#if book.author}{book.author}{/if} +
+ {/if} + +
+
+ +
+ + diff --git a/src/lib/books/BookPile.svelte b/src/lib/books/BookPile.svelte new file mode 100644 index 0000000..c65bdc8 --- /dev/null +++ b/src/lib/books/BookPile.svelte @@ -0,0 +1,130 @@ + + +
+ + + {#each laid as item, i (item.book.slug)} + 1 ? i / (laid.length - 1) : 1} + {onenter} + {onleave} + /> + {/each} +
+ + diff --git a/src/lib/books/BookPiles.svelte b/src/lib/books/BookPiles.svelte new file mode 100644 index 0000000..4501a66 --- /dev/null +++ b/src/lib/books/BookPiles.svelte @@ -0,0 +1,107 @@ + + +{#snippet arrangement(piles, columns)} +
+ {#each piles as pile, i (i)} + + {/each} +
+{/snippet} + +
+ {@render arrangement(one, 1)} +
+ + +
+ +
+

+ {resting} +

+ +
+ + {#if hiddenCount > 0 || expanded} + + {/if} +
diff --git a/src/lib/books/BookVolume.svelte b/src/lib/books/BookVolume.svelte new file mode 100644 index 0000000..e09a9cf --- /dev/null +++ b/src/lib/books/BookVolume.svelte @@ -0,0 +1,340 @@ + + + onenter?.(book)} + onmouseleave={() => onleave?.(book)} + onfocus={() => onenter?.(book)} + onblur={() => onleave?.(book)} +> + {#if top} + + + {/if} + {#if book.spine} + + {/if} + + + + {#if !book.spine} + + {/if} + + + diff --git a/src/lib/books/Rating.svelte b/src/lib/books/Rating.svelte new file mode 100644 index 0000000..d29bd30 --- /dev/null +++ b/src/lib/books/Rating.svelte @@ -0,0 +1,48 @@ + + +{#if marks.length} + + + {#each marks as mark, i} + + {/each} + +{/if} diff --git a/src/lib/books/bookMeta.js b/src/lib/books/bookMeta.js new file mode 100644 index 0000000..9cccb27 --- /dev/null +++ b/src/lib/books/bookMeta.js @@ -0,0 +1,31 @@ +/** + * Small formatting helpers shared by the shelf, the /books list and the + * individual book pages. + */ + +/** 'Mar 2026' — the resolution a finished-reading date actually deserves. */ +export function formatMonth(date) { + if (!date) return ''; + try { + // Safari is mad about dashes in date strings. + return new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' }).format( + new Date(String(date).replaceAll('-', '/')) + ); + } catch { + return ''; + } +} + +/** + * Ratings are stored 0–5 and may be half steps. Returns the five marks the + * rating renders as, so the markup stays a simple loop. + */ +export function ratingMarks(rating) { + const value = Number(rating); + if (!Number.isFinite(value) || value <= 0) return []; + return Array.from({ length: 5 }, (_, i) => { + if (value >= i + 1) return 'full'; + if (value >= i + 0.5) return 'half'; + return 'empty'; + }); +} diff --git a/src/lib/books/bookStyle.js b/src/lib/books/bookStyle.js new file mode 100644 index 0000000..af63113 --- /dev/null +++ b/src/lib/books/bookStyle.js @@ -0,0 +1,159 @@ +/** + * Shared look-up tables for the bookshelf. + * + * A book's spine geometry and cloth colour are derived deterministically from + * its slug so the shelf renders identically on the server and the client (no + * hydration reshuffle), while still looking irregular the way a real shelf + * does. Frontmatter can override either: `color` picks a cloth by name (or + * takes a raw hex), `pages` drives the spine thickness. + */ + +/** + * Book-cloth palette. Muted, slightly dusty binding colours — the site is + * monochrome, so these are the only colour on the page and they earn it by + * staying desaturated. `foil` is the stamped title colour for that cloth. + */ +export const CLOTHS = { + ink: { base: '#2e3a4b', edge: '#1b2430', foil: '#e7e1d3' }, + oxblood: { base: '#5c2c2c', edge: '#3a1b1b', foil: '#ecdfc7' }, + moss: { base: '#3c4a38', edge: '#242e22', foil: '#e4e2cd' }, + sand: { base: '#c6b596', edge: '#9c8c6f', foil: '#3b3225' }, + slate: { base: '#404c5d', edge: '#28303c', foil: '#e3e6e7' }, + rust: { base: '#8a4b30', edge: '#5a2f1d', foil: '#f1e5d3' }, + cream: { base: '#e0d7c5', edge: '#b6ab95', foil: '#3a332a' }, + charcoal: { base: '#2b2b2b', edge: '#171717', foil: '#dcd7cc' }, + plum: { base: '#4a3550', edge: '#2c1e31', foil: '#ebe1ee' }, + teal: { base: '#294a4a', edge: '#152e2e', foil: '#dee9e6' }, + navy: { base: '#26344f', edge: '#151f33', foil: '#e6e3d6' }, + ochre: { base: '#a1762f', edge: '#6d4d1a', foil: '#f6ecd8' } +}; + +const CLOTH_NAMES = Object.keys(CLOTHS); + +/** Stable 32-bit string hash (FNV-1a). Same value on server and client. */ +export function hashString(str) { + let h = 0x811c9dc5; + for (let i = 0; i < String(str).length; i++) { + h ^= String(str).charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** Perceived luminance of a #rrggbb colour, 0–1. */ +function luminance(hex) { + const m = /^#?([0-9a-f]{6})$/i.exec(String(hex)); + if (!m) return 0.3; + const n = parseInt(m[1], 16); + const r = (n >> 16) & 255; + const g = (n >> 8) & 255; + const b = n & 255; + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; +} + +/** + * Resolve the cloth for a book. `previousName` lets the caller avoid two + * identical spines sitting next to each other on the shelf. + * + * @param {{ slug?: string, color?: string }} book + * @param {string} [previousName] + */ +export function clothFor(book, previousName) { + const named = book?.color && CLOTHS[book.color]; + if (named) return { name: book.color, ...named }; + + // A raw hex in frontmatter wins; derive the rest of the cloth from it. + if (typeof book?.color === 'string' && /^#[0-9a-f]{6}$/i.test(book.color)) { + const light = luminance(book.color) > 0.55; + return { + name: book.color, + base: book.color, + edge: book.color, + foil: light ? '#3a332a' : '#e9e3d6' + }; + } + + const h = hashString(book?.slug ?? ''); + let index = h % CLOTH_NAMES.length; + if (previousName && CLOTH_NAMES[index] === previousName) { + index = (index + 5) % CLOTH_NAMES.length; + } + const name = CLOTH_NAMES[index]; + return { name, ...CLOTHS[name] }; +} + +/** True when the cloth is pale enough that the shelf needs a darker outline. */ +export function isPaleCloth(cloth) { + return luminance(cloth.base) > 0.55; +} + +/** + * Spine thickness in px, for the single book rendered on a book's own page. + * Real books get thicker with page count, so `pages` in frontmatter drives it + * when present; otherwise the slug hash does. + */ +export function spineThickness(book) { + const pages = Number(book?.pages); + if (Number.isFinite(pages) && pages > 0) { + return Math.round(Math.min(58, Math.max(24, 20 + pages / 11))); + } + return 28 + (hashString((book?.slug ?? '') + 'thickness') % 5) * 5; +} + +const round = (n) => Math.round(n * 100) / 100; +const clamp = (n, min, max) => Math.min(max, Math.max(min, n)); + +/** + * Geometry for a whole pile, bottom book first. + * + * Everything is expressed against the pile's own width (`cqw` / percent) so a + * pile is the same object at any size. + * + * The model is deliberately narrow, because gravity is: + * + * - **A book lying on a flat book is flat.** No in-plane rotation, ever. A + * spine tipped in the picture plane reads as a pile in the act of falling + * over, which is why tilting them individually never looks right. + * - **The one freedom it has is which way it is turned.** Nobody squares a + * book up with the one underneath, so each is yawed a few degrees about the + * vertical. Seen from slightly above and to the front, that turn is what + * puts one end of the spine nearer than the other — the near end taller, the + * far end shorter and riding up. The pile's `perspective` renders that; the + * apparent skew is a consequence of the turn rather than something drawn on + * top of it. + * - **Piles drift.** The sideways offset carries over from the book below + * instead of being redrawn at random, so a pile leans the way a real one does. + */ +export function pileGeometry(books) { + let previousShift = 0; + + return books.map((book) => { + const slug = book?.slug ?? ''; + const h = hashString(slug); + const pages = Number(book?.pages); + + const thickness = + Number.isFinite(pages) && pages > 0 + ? clamp(4.6 + pages / 46, 8, 21) + : 9 + (hashString(slug + 'thickness') % 8); + + return { + // Books in a pile are not all the same size, but they are close. + width: 88 + (hashString(slug + 'width') % 13), + thickness: round(thickness), + yaw: round((((h >>> 17) % 19) - 9) * 0.8), + shift: (previousShift = clamp(previousShift + ((((h >>> 11) % 9) - 4) * 1.5) / 2, -11, 11)) + }; + }); +} + +/** + * Spines print the surname. Anything with a comma or an ampersand is already a + * multi-author credit, so it stays as written. + */ +export function spineAuthor(name) { + const value = String(name ?? '').trim(); + if (!value || /[&,]/.test(value)) return value; + const parts = value.split(/\s+/); + return parts.length > 1 ? parts[parts.length - 1] : value; +} diff --git a/src/lib/og/build.js b/src/lib/og/build.js index c88ec8a..cde8d4f 100644 --- a/src/lib/og/build.js +++ b/src/lib/og/build.js @@ -5,6 +5,8 @@ import { blogPostTemplate, projectsListTemplate, projectTemplate, + booksListTemplate, + bookTemplate, nowTemplate } from './templates.js'; @@ -28,6 +30,7 @@ export function resolveOgInput(searchParams) { template: searchParams.get('template') || 'home', title: (searchParams.get('title') || '').slice(0, MAX_OG_TEXT), description: (searchParams.get('description') || '').slice(0, MAX_OG_TEXT), + author: (searchParams.get('author') || '').slice(0, MAX_OG_TEXT), date: (searchParams.get('date') || '').slice(0, MAX_OG_TEXT), year: (searchParams.get('year') || '').slice(0, MAX_OG_TEXT), tags: searchParams.get('tags') @@ -42,10 +45,18 @@ export function resolveOgInput(searchParams) { } /** - * @param {{ template: string, title?: string, description?: string, date?: string, year?: string, tags?: string[] }} input + * @param {{ template: string, title?: string, description?: string, author?: string, date?: string, year?: string, tags?: string[] }} input */ export function buildOgElement(input) { - const { template, title = '', description = '', date = '', year = '', tags = [] } = input; + const { + template, + title = '', + description = '', + author = '', + date = '', + year = '', + tags = [] + } = input; switch (template) { case 'home': @@ -58,6 +69,10 @@ export function buildOgElement(input) { return projectsListTemplate(); case 'project': return projectTemplate({ title, description, year, tags }); + case 'books': + return booksListTemplate(); + case 'book': + return bookTemplate({ title, description, author, date }); case 'now': return nowTemplate(); default: diff --git a/src/lib/og/build.test.js b/src/lib/og/build.test.js index 09c4b1b..bbf1c50 100644 --- a/src/lib/og/build.test.js +++ b/src/lib/og/build.test.js @@ -20,6 +20,7 @@ describe('resolveOgInput', () => { template: 'home', title: '', description: '', + author: '', date: '', year: '', tags: [] diff --git a/src/lib/og/fixtures.js b/src/lib/og/fixtures.js index c7f07a6..5778950 100644 --- a/src/lib/og/fixtures.js +++ b/src/lib/og/fixtures.js @@ -2,7 +2,7 @@ * Dummy OG card data for /api/og/test and ?fixture=… previews. */ -/** @type {Record} */ +/** @type {Record} */ export const OG_FIXTURES = { 'lg-ai-blog': { template: 'blog-post', @@ -33,9 +33,18 @@ export const OG_FIXTURES = { year: '2025', tags: ['SvelteKit', 'Convex', 'TypeScript', 'AI-assisted'] }, + 'ddia-book': { + template: 'book', + title: 'Designing Data-Intensive Applications', + author: 'Martin Kleppmann', + description: + 'What actually happens under a database, explained without pretending it is simple.', + date: '2026-07-08' + }, home: { template: 'home' }, blog: { template: 'blog' }, projects: { template: 'projects' }, + books: { template: 'books' }, now: { template: 'now' } }; diff --git a/src/lib/og/templates.js b/src/lib/og/templates.js index e79cc4d..8a456a8 100644 --- a/src/lib/og/templates.js +++ b/src/lib/og/templates.js @@ -350,6 +350,30 @@ export function blogPostTemplate({ title, description, date }) { ]); } +export function booksListTemplate() { + return container([ + mainColumn([ + label('Injoon Oh'), + displayText('Books'), + bodyText('Everything I have read and had something to say about.', { marginTop: 22 }) + ]), + bottomBar() + ]); +} + +export function bookTemplate({ title, description, author, date }) { + return container([ + mainColumn([ + label('책'), + titleText(title), + ...(author ? [descriptionText(author)] : []), + ...(description ? [descriptionText(description)] : []), + ...(date ? [metaText(formatDate(date))] : []) + ]), + bottomBar() + ]); +} + export function projectsListTemplate() { return container([ mainColumn([ diff --git a/src/lib/server/content-modules.js b/src/lib/server/content-modules.js index f85bf4b..b5ad730 100644 --- a/src/lib/server/content-modules.js +++ b/src/lib/server/content-modules.js @@ -12,3 +12,5 @@ export const projectEnModules = import.meta.glob('/src/routes/projects/projects/ export const projectKoModules = import.meta.glob('/src/routes/projects/projects/ko/*.md', { eager: true }); +export const bookEnModules = import.meta.glob('/src/routes/books/books/en/*.md', { eager: true }); +export const bookKoModules = import.meta.glob('/src/routes/books/books/ko/*.md', { eager: true }); diff --git a/src/lib/server/valid-urls.js b/src/lib/server/valid-urls.js index 1bb79be..fd1aa8b 100644 --- a/src/lib/server/valid-urls.js +++ b/src/lib/server/valid-urls.js @@ -2,7 +2,9 @@ import { blogEnModules, blogKoModules, projectEnModules, - projectKoModules + projectKoModules, + bookEnModules, + bookKoModules } from './content-modules.js'; import { resolvePublished } from './content'; @@ -14,6 +16,9 @@ for (const item of resolvePublished(blogEnModules, blogKoModules)) { for (const item of resolvePublished(projectEnModules, projectKoModules)) { validUrls.add('/projects/' + item.slug); } +for (const item of resolvePublished(bookEnModules, bookKoModules)) { + validUrls.add('/books/' + item.slug); +} /** @param {string} url */ export function isValidPageUrl(url) { diff --git a/src/lib/types.ts b/src/lib/types.ts index d340ee0..03aacf0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -25,6 +25,28 @@ export type Project = { hasEn?: boolean; }; +export type Book = { + title: string; + slug: string; + author: string; + description: string; + /** When the book was finished (or started, for one still being read). */ + date: string; + /** 0–5, half steps allowed. */ + rating?: number; + /** Cloth name from `CLOTHS` in `$lib/books/bookStyle.js`, or a raw hex. */ + color?: string; + /** Page count — drives how thick the spine is on the shelf. */ + pages?: number; + /** Front cover artwork; falls back to a stamped cloth cover when absent. */ + cover?: string; + /** Spine artwork; falls back to stamped cloth + foil type when absent. */ + spine?: string; + reading?: boolean; + published: boolean; + hasEn?: boolean; +}; + export type Tags = { name: string; slug: string; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b326fc5..7c5a4d2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3,6 +3,7 @@ import { heroNameVisible } from '$lib/heroNav.js'; import { marqueePauseWhenOffscreen, marqueeConstantSpeed } from '$lib/actions/marquee.js'; import TechStack from '$lib/TechStack.svelte'; + import BookPiles from '$lib/books/BookPiles.svelte'; import { techstack } from '$lib/techstack-data.js'; // The hero shows the big "Injoon Oh"; once it scrolls out of view the navbar @@ -254,6 +255,46 @@ + +{#if data.books.length} +
+
+
+ +

+ Books +

+
+

+ The pile by the desk.
Whatever is on top is what I am in. +

+
+
+ +
+{/if} +
{ - const [postResponse, projectsResponse] = await Promise.all([ + const [postResponse, projectsResponse, booksResponse] = await Promise.all([ fetch(`/api/posts`), - fetch(`/api/projects`) + fetch(`/api/projects`), + fetch(`/api/books`) + ]); + const [posts, projects, books] = await Promise.all([ + postResponse.json(), + projectsResponse.json(), + booksResponse.json() ]); - const [posts, projects] = await Promise.all([postResponse.json(), projectsResponse.json()]); return { posts, - projects + projects, + books }; }; diff --git a/src/routes/api/books/+server.ts b/src/routes/api/books/+server.ts new file mode 100644 index 0000000..7159eb8 --- /dev/null +++ b/src/routes/api/books/+server.ts @@ -0,0 +1,16 @@ +export const prerender = true; + +import { json } from '@sveltejs/kit'; +import type { Book } from '$lib/types'; +import { resolvePublished, CONTENT_CACHE_CONTROL } from '$lib/server/content'; +import { bookEnModules, bookKoModules } from '$lib/server/content-modules.js'; + +export async function GET() { + const books = (resolvePublished(bookEnModules, bookKoModules) as Book[]).sort((a, b) => { + // Books still being read sit at the front of the shelf. + if (!!a.reading !== !!b.reading) return a.reading ? -1 : 1; + return new Date(b.date).getTime() - new Date(a.date).getTime(); + }); + + return json(books, { headers: { 'Cache-Control': CONTENT_CACHE_CONTROL } }); +} diff --git a/src/routes/books/+page.svelte b/src/routes/books/+page.svelte new file mode 100644 index 0000000..ecb0e10 --- /dev/null +++ b/src/routes/books/+page.svelte @@ -0,0 +1,92 @@ + + + + Books - Injoon Oh + + + + + + + + + +
+

+ Books +

+

+ Everything I have read and had something to say about.
Take one off the top. +

+ + {#if data.books.length} +
+ +
+ + + {:else} +

Nothing on the pile yet.

+ {/if} +
diff --git a/src/routes/books/+page.ts b/src/routes/books/+page.ts new file mode 100644 index 0000000..bb525d9 --- /dev/null +++ b/src/routes/books/+page.ts @@ -0,0 +1,12 @@ +export const prerender = true; + +import type { LoadEvent } from '@sveltejs/kit'; + +export const load = async ({ fetch }: LoadEvent) => { + const response = await fetch(`/api/books`); + const books = await response.json(); + + return { + books + }; +}; diff --git a/src/routes/books/[slug]/+page.server.ts b/src/routes/books/[slug]/+page.server.ts new file mode 100644 index 0000000..c70a4f4 --- /dev/null +++ b/src/routes/books/[slug]/+page.server.ts @@ -0,0 +1 @@ +export { load } from '$lib/server/content-page.js'; diff --git a/src/routes/books/[slug]/+page.svelte b/src/routes/books/[slug]/+page.svelte new file mode 100644 index 0000000..ed2b3b9 --- /dev/null +++ b/src/routes/books/[slug]/+page.svelte @@ -0,0 +1,301 @@ + + + + {headMeta.title} + + + + + + + + {#each data.availableLangs as l} + + {/each} + + + + + + + +
+
+ +
+
+ +
+ +
+
+
+ {#each [displayLang] as l (l)} +

+ {metaFor(l).title} +

+ {/each} +
+
+ + {#if currentMeta.author} +
+
+ {#each [displayLang] as l (l)} +

+ {metaFor(l).author} +

+ {/each} +
+
+ {/if} + +
+ {#if currentMeta.rating} + + + {currentMeta.rating.toFixed?.(1) ?? currentMeta.rating} + · + {/if} + {#if currentMeta.reading} + Reading now + {:else if currentMeta.date} + Finished {formatDate(currentMeta.date)} + {/if} + {#if currentMeta.pages} + · + {currentMeta.pages} pages + {/if} +
+ + {#if currentMeta.description} +
+
+ {#each [displayLang] as l (l)} +

+ {metaFor(l).description} +

+ {/each} +
+
+ {/if} + + +
+ +
+
+
+ +
+ {#each [displayLang] as l (l)} + {@const content = contentFor(l)} +
+ {#if metaFor(l)?.aiTranslated} +
+
+ {/if} +
+ {#if content} + + {/if} +
+
+ {/each} +
+ +
+ +
+
+
diff --git a/src/routes/books/[slug]/+page.ts b/src/routes/books/[slug]/+page.ts new file mode 100644 index 0000000..b65e95e --- /dev/null +++ b/src/routes/books/[slug]/+page.ts @@ -0,0 +1,24 @@ +// Server-rendered (not prerendered) so the cookie/?lang= preferred language +// is applied on the server — the first byte is the right language, no flash. +export const prerender = false; + +import { error } from '@sveltejs/kit'; +import { resolveBilingualEntry, bilingualPageData } from '$lib/content/bilingual.js'; + +const enModules = import.meta.glob('../books/en/*.md'); +const koModules = import.meta.glob('../books/ko/*.md'); + +export async function load({ params, data }) { + const { en, ko } = await resolveBilingualEntry( + enModules, + koModules, + `../books/en/${params.slug}.md`, + `../books/ko/${params.slug}.md` + ); + + if (!en && !ko) { + throw error(404, `Could not find ${params.slug}`); + } + + return bilingualPageData(en, ko, data); +} diff --git a/src/routes/books/books/README.md b/src/routes/books/books/README.md new file mode 100644 index 0000000..4a8816b --- /dev/null +++ b/src/routes/books/books/README.md @@ -0,0 +1,55 @@ +# Books + +One markdown file per book, exactly like `blog/posts` and `projects/projects`: +English in `en/`, Korean in `ko/`, same filename for both. A slug with both +files gets the language switcher; Korean wins as the default, and the list +marks the entry `EN`. + +Drop a file in, and it appears in the pile on `/books`, on the home page, and +at `/books/`. Nothing else to register. + +## Frontmatter + +```yaml +--- +type: book +title: 'The Pragmatic Programmer' # required +slug: the-pragmatic-programmer # required, must match the filename +author: Hunt & Thomas # printed on the spine (surname only, unless it has a comma or an &) +description: One line, shown under the title and in the pile readout. +date: '2026-04-11' # when you finished it +rating: 4.5 # 0-5, half steps allowed. Omit for no stars. +pages: 352 # drives how thick the spine is in the pile +color: moss # binding cloth, see below. Omit and one is picked from the slug. +cover: '' # front cover artwork, e.g. /images/uploads/books/dune.jpg +spine: '' # spine artwork, same idea +reading: true # still reading it: it sits on top and the readout says so +published: true # required — nothing renders without it +aiTranslated: true # shows the translation notice, same as blog posts +--- +``` + +Everything below the frontmatter is your notes, in normal markdown. Same +pipeline as blog posts, so code blocks, math, images and embeds all work. + +## Covers + +Put images in `static/images/uploads/books/` and point `cover` (and `spine`, +if you have it) at them. + +- `cover` shows on the book's own page, turned to face the reader, and on the + top board of a pile. +- `spine` replaces the stamped cloth spine in the pile. Without it the spine is + printed from the title and author, which is usually the better-looking option + unless you have a clean scan. + +A cover wants to be roughly 2:3 and at least 600px wide. Anything is cropped to +fit, so nothing breaks if it is not. + +## Cloth colours + +`color` takes one of the named cloths — `ink`, `oxblood`, `moss`, `sand`, +`slate`, `rust`, `cream`, `charcoal`, `plum`, `teal`, `navy`, `ochre` — or a +raw `#rrggbb`, in which case the foil colour is picked for contrast. Leave it +out and the cloth is derived from the slug, which stays stable as you add +books. diff --git a/src/routes/books/books/en/designing-data-intensive-applications.md b/src/routes/books/books/en/designing-data-intensive-applications.md new file mode 100644 index 0000000..8866646 --- /dev/null +++ b/src/routes/books/books/en/designing-data-intensive-applications.md @@ -0,0 +1,30 @@ +--- +type: book +title: 'Designing Data-Intensive Applications' +slug: designing-data-intensive-applications +author: Martin Kleppmann +description: What actually happens under a database, explained without pretending it is simple. +date: '2026-07-08' +rating: 5 +pages: 616 +color: cream +cover: '' +spine: '' +reading: true +published: true +--- + +## Where I am + +About halfway, somewhere in replication. Slower going than anything else on +this shelf, and the only book here I am reading with a notebook open. + +## Notes so far + +Every chapter follows the same shape: here is a thing you assumed was free, +here is what it costs, here is what people do about it. Ordering. Consistency. +Time. Especially time — I had no idea how much of distributed systems is +arguing about what "before" means. + +The failure diagrams alone were worth it. I have started drawing them for my own +projects, badly. diff --git a/src/routes/books/books/en/godel-escher-bach.md b/src/routes/books/books/en/godel-escher-bach.md new file mode 100644 index 0000000..416b3f0 --- /dev/null +++ b/src/routes/books/books/en/godel-escher-bach.md @@ -0,0 +1,35 @@ +--- +type: book +title: 'Godel, Escher, Bach' +slug: godel-escher-bach +author: Douglas Hofstadter +description: A very long book about how meaning comes out of things that have no meaning in them. +date: '2026-05-02' +rating: 4.5 +pages: 777 +color: ink +cover: '' +spine: '' +published: true +--- + +## Why I picked it up + +Someone described it to me as "a book about how a mind can come out of matter", +which is exactly the kind of sentence that makes me buy a book I am not ready +for. It took me most of a semester. + +## What stuck + +The idea I keep coming back to is the strange loop — a system that climbs back +to where it started by going up. Formal systems that talk about themselves, +canons that modulate into their own key, drawings whose hands draw each other. + +Once you have the shape in your head you start seeing it everywhere, which is +either evidence that it is a deep idea or evidence that I have been reading too +much Hofstadter. + +## What I would skip + +The dialogues are the best part and the hardest part. I read a few of them +twice, and I skimmed two of them completely. That is allowed. diff --git a/src/routes/books/books/en/sapiens.md b/src/routes/books/books/en/sapiens.md new file mode 100644 index 0000000..f9888dc --- /dev/null +++ b/src/routes/books/books/en/sapiens.md @@ -0,0 +1,28 @@ +--- +type: book +title: 'Sapiens' +slug: sapiens +author: Yuval Noah Harari +description: A history of everyone, told fast enough that you notice the corners being cut. +date: '2025-12-28' +rating: 4 +pages: 443 +color: sand +cover: '' +spine: '' +published: true +aiTranslated: false +--- + +## Notes + +The argument that stayed with me is that the things holding a large group of +people together — money, nations, companies — only work because everyone agrees +to act as if they are real. That is a strange and useful thing to notice at +sixteen. + +## Where I pushed back + +It moves very fast, and the speed hides the disagreements. Whole debates among +historians get one confident sentence. I liked the book more when I started +treating it as a very good set of questions rather than a set of answers. diff --git a/src/routes/books/books/en/surely-youre-joking.md b/src/routes/books/books/en/surely-youre-joking.md new file mode 100644 index 0000000..e7c3b32 --- /dev/null +++ b/src/routes/books/books/en/surely-youre-joking.md @@ -0,0 +1,30 @@ +--- +type: book +title: "Surely You're Joking, Mr. Feynman!" +slug: surely-youre-joking +author: Richard Feynman +description: A physicist being curious in public, which turns out to be the whole method. +date: '2025-08-16' +rating: 5 +pages: 350 +color: rust +cover: '' +spine: '' +published: true +--- + +## Notes + +Not really a science book. It is a book about refusing to be bored, told as a +string of stories about safes, drums, drawing lessons and Brazilian physics +education. + +The chapter on the Brazilian textbooks is the one I would make everyone read. +Students who could recite every definition and could not answer a single +question about the world outside the definition. I recognised more of my own +schooling in it than I wanted to. + +## Line I keep + +The first principle is that you must not fool yourself, and you are the easiest +person to fool. diff --git a/src/routes/books/books/en/the-art-of-doing-science-and-engineering.md b/src/routes/books/books/en/the-art-of-doing-science-and-engineering.md new file mode 100644 index 0000000..62eee13 --- /dev/null +++ b/src/routes/books/books/en/the-art-of-doing-science-and-engineering.md @@ -0,0 +1,29 @@ +--- +type: book +title: 'The Art of Doing Science and Engineering' +slug: the-art-of-doing-science-and-engineering +author: Richard Hamming +description: A course on how to pick problems worth working on, disguised as a course on computing. +date: '2026-06-14' +rating: 4.5 +pages: 432 +color: navy +cover: '' +spine: '' +published: true +--- + +## Notes + +Hamming spends less time on how to solve a problem than on how to choose one, +which is the part school never covers at all. + +The famous chapter asks what the important problems in your field are, and then +asks why you are not working on them. It is a rude question and it is supposed +to be. + +## What I took from it + +Keep a list of open problems you care about. Work with your door open. Compound +small amounts of effort in a direction rather than large amounts in whatever +direction is nearest. diff --git a/src/routes/books/books/en/the-design-of-everyday-things.md b/src/routes/books/books/en/the-design-of-everyday-things.md new file mode 100644 index 0000000..60c97bb --- /dev/null +++ b/src/routes/books/books/en/the-design-of-everyday-things.md @@ -0,0 +1,30 @@ +--- +type: book +title: 'The Design of Everyday Things' +slug: the-design-of-everyday-things +author: Don Norman +description: If you have to push a door that says push, someone made a mistake and it was not you. +date: '2025-06-21' +rating: 4 +pages: 368 +color: teal +cover: '' +spine: '' +published: true +--- + +## Notes + +Affordances, signifiers, feedback, mapping. Four words that made me +insufferable for about a month, because suddenly every microwave in the house +was worth complaining about. + +The useful part for me was the argument that blaming the user is almost always +the lazy answer. I have caught myself writing an error message that blamed the +person reading it more than once since. + +## Applied + +Redid the settings screen on one of my projects after this. Fewer options, +clearer defaults, and the destructive action stopped sitting next to the safe +one. diff --git a/src/routes/books/books/en/the-pragmatic-programmer.md b/src/routes/books/books/en/the-pragmatic-programmer.md new file mode 100644 index 0000000..98cf402 --- /dev/null +++ b/src/routes/books/books/en/the-pragmatic-programmer.md @@ -0,0 +1,34 @@ +--- +type: book +title: 'The Pragmatic Programmer' +slug: the-pragmatic-programmer +author: Hunt & Thomas +description: The book that finally explained why my old projects were so hard to come back to. +date: '2026-04-11' +rating: 4.5 +pages: 352 +color: moss +cover: '' +spine: '' +published: true +--- + +## Notes + +Most programming books teach you a language. This one teaches you the habits +you are supposed to have picked up on your own and never did. + +The chapter on orthogonality is the one I would hand to past me. Every project +I abandoned, I abandoned because changing one thing meant changing nine. + +## Lines I wrote down + +- Don't live with broken windows. +- Make it easy to reuse, or people will not reuse it. +- Your knowledge has a half-life. Keep investing. + +## After + +I started keeping an engineering notebook because of this book, and I have kept +it for four months, which is longer than any habit I have started from a +YouTube video. diff --git a/src/routes/books/books/en/the-selfish-gene.md b/src/routes/books/books/en/the-selfish-gene.md new file mode 100644 index 0000000..057fd07 --- /dev/null +++ b/src/routes/books/books/en/the-selfish-gene.md @@ -0,0 +1,26 @@ +--- +type: book +title: 'The Selfish Gene' +slug: the-selfish-gene +author: Richard Dawkins +description: Change the unit you are looking at and the whole picture reorganises itself. +date: '2025-10-05' +rating: 4.5 +pages: 360 +color: oxblood +cover: '' +spine: '' +published: true +--- + +## Notes + +The trick of the book is a change of viewpoint, not a change of facts. Stop +asking what is good for the animal and ask what is good for the gene, and a pile +of behaviour that looked contradictory lines up. + +## The part nobody warns you about + +The last chapter invents memes almost as an afterthought, and it is the part of +the book everyone has heard of. Reading it now, decades later, with the internet +outside the window, is a slightly eerie experience. diff --git a/src/routes/books/books/en/thinking-fast-and-slow.md b/src/routes/books/books/en/thinking-fast-and-slow.md new file mode 100644 index 0000000..11d8ae9 --- /dev/null +++ b/src/routes/books/books/en/thinking-fast-and-slow.md @@ -0,0 +1,30 @@ +--- +type: book +title: 'Thinking, Fast and Slow' +slug: thinking-fast-and-slow +author: Daniel Kahneman +description: Two systems, one of which is lazy and confident, and it is running most of the time. +date: '2026-02-19' +rating: 4 +pages: 499 +color: slate +cover: '' +spine: '' +published: true +--- + +## Notes + +The framing is simple enough to explain at dinner: a fast system that guesses +and a slow system that checks, and the fast one is in charge far more often +than anyone would like. + +What I did not expect was how much of the book is about how hard it is to fix +this in yourself. Knowing about anchoring does not stop anchoring. It only +means you get to watch it happen. + +## Caveat + +Some of the priming studies have not replicated well, and the book is confident +about them in a way it probably should not be. Worth reading with that in mind +rather than not reading. diff --git a/src/routes/books/books/ko/sapiens.md b/src/routes/books/books/ko/sapiens.md new file mode 100644 index 0000000..a249ed9 --- /dev/null +++ b/src/routes/books/books/ko/sapiens.md @@ -0,0 +1,26 @@ +--- +type: book +title: '사피엔스' +slug: sapiens +author: 유발 하라리 +description: 모두의 역사를, 생략된 부분이 보일 만큼 빠르게 훑는 책. +date: '2025-12-28' +rating: 4 +pages: 443 +color: sand +cover: '' +spine: '' +published: true +--- + +## 메모 + +가장 오래 남은 주장은, 많은 사람을 하나로 묶는 것들 — 돈, 국가, 회사 — 이 +모두가 그것을 진짜인 것처럼 대하기로 합의했기 때문에만 작동한다는 이야기였다. +열여섯 살에 알아채기에는 꽤 이상하고 유용한 사실이다. + +## 아쉬운 점 + +전개가 아주 빠르고, 그 속도가 학자들 사이의 이견을 가려버린다. 긴 논쟁이 +자신감 있는 한 문장으로 정리되곤 한다. 답의 모음이 아니라 좋은 질문의 모음으로 +읽기 시작하니 훨씬 좋아졌다. diff --git a/src/routes/books/books/ko/surely-youre-joking.md b/src/routes/books/books/ko/surely-youre-joking.md new file mode 100644 index 0000000..615c0c3 --- /dev/null +++ b/src/routes/books/books/ko/surely-youre-joking.md @@ -0,0 +1,28 @@ +--- +type: book +title: '파인만 씨, 농담도 잘하시네!' +slug: surely-youre-joking +author: 리처드 파인만 +description: 물리학자가 공개적으로 호기심을 부리는 이야기. 알고 보면 그게 방법론 전부다. +date: '2025-08-16' +rating: 5 +pages: 350 +color: rust +cover: '' +spine: '' +published: true +--- + +## 메모 + +사실 과학책은 아니다. 금고, 드럼, 그림 수업, 브라질 물리 교육에 대한 이야기를 +늘어놓으며 "지루해지기를 거부하는 법"을 말하는 책에 가깝다. + +브라질 교과서 이야기는 모두가 읽었으면 한다. 정의는 하나도 빠짐없이 외우면서 +정의 바깥의 질문에는 한 마디도 답하지 못하는 학생들. 내 학교생활이 생각보다 +많이 겹쳐 보여서 조금 불편했다. + +## 남겨둔 문장 + +첫 번째 원칙은 자기 자신을 속이지 않는 것이다. 그리고 자신은 가장 속이기 쉬운 +사람이다.