Automated pipeline from Obsidian reading notes to a published bookshelf site. No CMS, no manual publishing — write notes in your vault, run one command, push.
Live: yoni-bookshelf.netlify.app · 71 books tracked since 2025
I read a lot and keep notes in Obsidian. The problem: those notes just sit there. I wanted a browsable, searchable reading log that feels like a personal library — without maintaining a CMS or manually updating a website.
So I built a pipeline:
Obsidian vault (single source of truth)
│
├─ sync-books.sh
│ Filters publishable books (완독 / 읽는 중)
│ Strips Obsidian artifacts (Kindle refs, local images, redundant metadata)
│ Normalizes content (heading cleanup, blockquote formatting)
│ Filters out Japanese-language entries
│
├─ download-covers.mjs
│ Downloads cover images from Aladin / YES24 / Millie CDNs
│ Converts to WebP via sharp (500px, 82% quality)
│ Rewrites frontmatter `cover:` to local `/covers/{isbn}.webp`
│ Concurrent batches of 5, skips already-cached
│
└─ Astro SSG build → static HTML
│
└─ git push → Netlify auto-deploys
The Obsidian vault stays canonical. Everything downstream is derived and disposable.
Content normalization (sync-books.sh): Obsidian notes are messy — they have Kindle location refs (— location: [1234](kindle://...)), ^ref- anchors, redundant inline metadata lines, local image paths that won't work outside the vault, and inconsistent heading names. The sync script handles all of this with regex transforms so the published content is clean without manual editing.
Permissive schema (content/config.ts): Every frontmatter field is flexStr — nullable, and accepts strings, numbers, or dates. This is intentional: Obsidian frontmatter is inconsistent by nature (I mix Korean and English field names, formats change over time), and I'd rather the schema be forgiving than have to maintain strict validation across hundreds of notes.
Cover image pipeline (download-covers.mjs): Instead of hotlinking external CDN images (which can break or get blocked — looking at you, img.millie.co.kr), this downloads and converts covers to optimized WebP. It caches by ISBN, so re-running is idempotent. Batch concurrency keeps it fast.
Date parsing (utils/books.ts): Reading dates come in multiple formats — explicit startdate/enddate fields, legacy 읽은날: 2025/01/01 → 2025/01/15 strings, even Astro's Date → ISO timestamp serialization. The getStartDate/getEndDate functions handle all of these with fallback chains.
Highlight scraping: Quotes come from Millie (밀리의서재), a Korean e-reader app that highlights your annotations. I built a browser-based scraper that logs into Millie, navigates each book's highlight page, and extracts the text into Obsidian markdown. The sync script then normalizes these into blockquotes under ## 발췌 sections.
Slug generation: ISBN-based when available (stable, unique), title-derived as fallback (lowercase, stripped, max 50 chars, deduplicated with a counter). Both index.astro and [slug].astro reset slug counters before generating to avoid collisions.
- Genre filter chips, real-time search, sort by date/title/author
- Dark/light mode with localStorage persistence
- Per-book detail pages with cover, metadata, and curated quotes
- Reading stats (total, this year, this month, top genre)
- 3D CSS hover effect on book covers with spine shadow
- Sitemap, OG tags, 404 page
| Layer | Choice |
|---|---|
| Notes | Obsidian vault |
| Build | Astro v5 (SSG, no JS framework) |
| Image processing | sharp (WebP conversion) |
| Hosting | Netlify (auto-deploy on push, aggressive cache headers) |
| Fonts | Pretendard Variable (body), Noto Serif KR (headings), JetBrains Mono (code) |
src/
├── content/
│ ├── books/ # synced from Obsidian (committed to git)
│ └── config.ts # permissive Zod schema for book frontmatter
├── pages/
│ ├── index.astro # book grid + filters + search + sort
│ ├── books/[slug].astro # individual book detail
│ └── reader.astro # about the reader
├── layouts/BaseLayout.astro
├── styles/global.css # all styles, design tokens
├── utils/books.ts # slug gen, date parsing, sort, detectLang
└── config.ts # site metadata
scripts/
├── sync-books.sh # vault → content collection (filter, normalize, clean)
└── download-covers.mjs # CDN → local WebP (download, convert, cache)
netlify.toml # build config, immutable cache headers