astro-book-bridge 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gabriel Kanev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # astro-book-bridge
2
+
3
+ Astro integration for a local, source-agnostic book catalog. It can import a
4
+ Goodreads RSS shelf, fetch edition metadata from Open Library and Google Books,
5
+ and merge all of it with notes, reviews and curation stored in the Astro
6
+ project.
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pnpm add astro-book-bridge
12
+ ```
13
+
14
+ Register the integration in `astro.config.mjs`:
15
+
16
+ ```js
17
+ import { defineConfig } from 'astro/config';
18
+ import bookBridge from 'astro-book-bridge';
19
+
20
+ export default defineConfig({
21
+ integrations: [
22
+ bookBridge({
23
+ rssUrl: 'https://www.goodreads.com/review/list_rss/USER_ID?shelf=read',
24
+ openLibrary: true,
25
+ googleBooks: { apiKey: process.env.GOOGLE_BOOKS_API_KEY },
26
+ overrides: 'src/content/book-overrides.json',
27
+ markdownOverrides: 'src/content/book-overrides',
28
+ cache: '.astro/book-bridge',
29
+ covers: { mode: 'local', directory: 'public/book-covers' },
30
+ output: 'src/generated/books.json', // optional
31
+ }),
32
+ ],
33
+ });
34
+ ```
35
+
36
+ ## Use in an Astro page
37
+
38
+ ```astro
39
+ ---
40
+ import { books, catalog } from 'astro-book-bridge:catalog';
41
+ ---
42
+
43
+ <h1>Books ({books.length})</h1>
44
+ {catalog.source.usedCache && <p>Showing the last cached RSS feed.</p>}
45
+ <ul>
46
+ {books.map((book) => <li><a href={book.link}>{book.title}</a> — {book.author}</li>)}
47
+ </ul>
48
+ ```
49
+
50
+ The integration injects the TypeScript declaration for the virtual module into
51
+ Astro automatically. Each book exposes its primary `source` and all merged
52
+ `sources`, e.g. `['goodreads-rss', 'open-library']`.
53
+
54
+ ## Sources
55
+
56
+ `rssUrl` is optional. With it, Goodreads continues to provide reading state
57
+ (rating, date read and shelf), while the other providers fill richer edition
58
+ metadata where ISBNs match.
59
+
60
+ ```js
61
+ bookBridge({
62
+ rssUrl: 'https://www.goodreads.com/review/list_rss/USER_ID?shelf=read',
63
+ openLibrary: true,
64
+ googleBooks: true,
65
+ });
66
+ ```
67
+
68
+ For a catalog with no Goodreads dependency, supply ISBNs directly to either
69
+ provider. `enrich: false` prevents the provider from also looking up ISBNs
70
+ already returned by another source.
71
+
72
+ ```js
73
+ bookBridge({
74
+ openLibrary: {
75
+ isbns: ['9780140328721', '9780441478125'],
76
+ enrich: false,
77
+ },
78
+ googleBooks: {
79
+ isbns: ['9780140328721'],
80
+ enrich: false,
81
+ apiKey: process.env.GOOGLE_BOOKS_API_KEY,
82
+ },
83
+ });
84
+ ```
85
+
86
+ Open Library is queried through its ISBN Books API. Google Books is queried by
87
+ ISBN; an API key is optional in the integration configuration and lets a site
88
+ use its own Google quota/credentials. Provider order is Goodreads → Open
89
+ Library → Google Books, so later sources only fill missing remote fields.
90
+
91
+ ## Local overrides
92
+
93
+ Create `src/content/book-overrides.json`. A book matches Goodreads ID first,
94
+ then ISBN13, then ISBN. This rule works no matter which provider supplied the
95
+ book. Local fields only replace remote fields when they are present.
96
+
97
+ ```json
98
+ {
99
+ "books": [
100
+ {
101
+ "goodreadsId": "18423",
102
+ "slug": "the-left-hand-of-darkness",
103
+ "note": "A reread that gets better every time.",
104
+ "review": "My full review in Markdown or plain text.",
105
+ "recommended": true,
106
+ "featured": true,
107
+ "tags": ["science-fiction", "classics"]
108
+ },
109
+ {
110
+ "isbn13": "9780441478125",
111
+ "note": "This also matches when Goodreads ID is absent."
112
+ }
113
+ ]
114
+ }
115
+ ```
116
+
117
+ Duplicate Goodreads IDs, ISBNs or ISBN13 values are rejected with a build
118
+ error. ISBN punctuation is ignored when matching, and supplied ISBNs must pass
119
+ their ISBN-10/ISBN-13 checksum.
120
+
121
+ ### Markdown and MDX reviews
122
+
123
+ For a longer review, add one `.md` or `.mdx` file per book under
124
+ `src/content/book-overrides/`. The YAML frontmatter identifies the book and the
125
+ body becomes `review` automatically.
126
+
127
+ ```md
128
+ ---
129
+ isbn13: "9780441478125"
130
+ featured: true
131
+ tags: [science-fiction, classics]
132
+ ---
133
+
134
+ # A returning favorite
135
+
136
+ My long-form review goes here in Markdown.
137
+ ```
138
+
139
+ JSON and Markdown overrides are merged together. Duplicated identity keys are
140
+ still reported as a build error, which prevents two reviews from silently
141
+ targeting the same book.
142
+
143
+ ## Operational behaviour
144
+
145
+ - Configured providers are fetched when the virtual module is built or loaded.
146
+ - The last successful RSS, Open Library and Google Books responses are cached
147
+ independently under `.astro/book-bridge/` by default. A changed ISBN list
148
+ gets a fresh provider cache entry.
149
+ - When a provider is temporarily unavailable, its matching cache is used by
150
+ default with a warning. Set `staleIfError: false` to fail instead.
151
+ - A failed metadata enrichment never discards data already returned by another
152
+ configured source.
153
+ - Set `conflicts: 'error'` to stop the build when two providers return
154
+ different titles for the same Goodreads ID/ISBN. The default is a warning
155
+ that keeps the first configured source.
156
+ - Editing the JSON overrides file triggers a full reload in development.
157
+ - Set `cache: false` to disable all provider caches; `timeoutMs` defaults to
158
+ `10000`. A legacy `cache: '.astro/feed.json'` path continues to work.
159
+
160
+ ### Cover policy and attribution
161
+
162
+ Remote cover URLs are retained by default. Set `covers.mode` to `local` to
163
+ download provider covers into a directory below `public/`, which makes the
164
+ catalog more reliable and avoids depending on a remote image at page load.
165
+ Every book retains `coverSourceUrl` and `coverAttribution` for a visible credit
166
+ or link in your template.
167
+
168
+ ```js
169
+ bookBridge({
170
+ openLibrary: { isbns: ['9780140328721'] },
171
+ covers: {
172
+ mode: 'local',
173
+ directory: 'public/book-covers',
174
+ fallbackUrl: '/book-cover-fallback.svg',
175
+ },
176
+ });
177
+ ```
178
+
179
+ See [PLAN.md](./PLAN.md) for the delivery plan and next milestones.
180
+
181
+ ## Copy-ready design demo
182
+
183
+ `examples/demo` is a small Astro app in this repository, built as a collection
184
+ of sections rather than a full site. It deliberately includes no navigation,
185
+ header or site layout, so users can drop the components into their own design
186
+ system.
187
+
188
+ ```bash
189
+ pnpm install
190
+ pnpm dev:demo
191
+ ```
192
+
193
+ The three copy-ready directions are all driven by the same `books` array from
194
+ the virtual module:
195
+
196
+ - `BookSections.astro` / `/` — bright, annotation-led **Margin Notes**.
197
+ - `EditorialSections.astro` / `/editorial` — calm, long-form reading journal.
198
+ - `DarkShelfSections.astro` / `/dark-shelf` — dark visual shelf with selected
199
+ book detail.
200
+
201
+ All three use semantic headings, lists and cover alt text, provide compact
202
+ mobile layouts, and avoid site-level chrome.
203
+
204
+ Use `pnpm check:demo` for Astro diagnostics and `pnpm build:demo` to build all
205
+ three pages as a static Astro site.
206
+
207
+ ## Quality and releases
208
+
209
+ The repository uses pnpm and includes CI for type checks, unit tests, package
210
+ build, Astro diagnostics, demo build and `pnpm pack --dry-run`. Add a release
211
+ note with `pnpm changeset`; the release workflow then creates a version PR and,
212
+ after it merges, publishes with the repository's `NPM_TOKEN` secret.
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from 'astro/config';
2
+ import bookBridge from 'astro-book-bridge';
3
+
4
+ export default defineConfig({
5
+ integrations: [
6
+ bookBridge({
7
+ openLibrary: {
8
+ isbns: ['9780140328721', '9780441478125', '9780061120084', '9780141182803', '9780307277671', '9780385490818'],
9
+ enrich: false,
10
+ },
11
+ }),
12
+ ],
13
+ });
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@astro-book-bridge/demo",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "astro dev",
8
+ "build": "astro build",
9
+ "check": "astro check",
10
+ "preview": "astro preview"
11
+ },
12
+ "dependencies": {
13
+ "astro-book-bridge": "workspace:*"
14
+ },
15
+ "devDependencies": {
16
+ "astro": "^7.1.3"
17
+ }
18
+ }
@@ -0,0 +1,44 @@
1
+ ---
2
+ import type { Book } from 'astro-book-bridge';
3
+
4
+ interface Props {
5
+ books: Book[];
6
+ }
7
+
8
+ const { books } = Astro.props;
9
+ ---
10
+
11
+ <section class="book-gallery" aria-labelledby="library-heading">
12
+ <div class="section-heading">
13
+ <div>
14
+ <p class="eyebrow">Your catalog</p>
15
+ <h2 id="library-heading">Books worth passing on</h2>
16
+ </div>
17
+ <span class="book-count">{books.length} books</span>
18
+ </div>
19
+
20
+ <ul class="book-filters" aria-label="Book categories">
21
+ <li class="filter is-active">All books</li>
22
+ <li class="filter">Fiction</li>
23
+ <li class="filter">Nonfiction</li>
24
+ <li class="filter">Poetry</li>
25
+ <li class="filter">Essays</li>
26
+ </ul>
27
+
28
+ <div class="book-grid">
29
+ {books.map((book, index) => (
30
+ <article class:list={['book-card', { 'book-card--offset': index % 5 === 1 || index % 5 === 4 }]}>
31
+ {book.imageUrl ? (
32
+ <img class="book-cover" src={book.imageUrl} alt={`Cover of ${book.title}`} loading="lazy" />
33
+ ) : (
34
+ <p class="book-no-cover">Cover unavailable</p>
35
+ )}
36
+ <div class="book-copy">
37
+ <h3>{book.title}</h3>
38
+ {book.author && <p>{book.author}</p>}
39
+ {book.note && <p class="book-note">{book.note}</p>}
40
+ </div>
41
+ </article>
42
+ ))}
43
+ </div>
44
+ </section>
@@ -0,0 +1,22 @@
1
+ ---
2
+ import type { Book } from 'astro-book-bridge';
3
+ import BookGallery from './BookGallery.astro';
4
+ import FeaturedNote from './FeaturedNote.astro';
5
+ import ReadingList from './ReadingList.astro';
6
+
7
+ interface Props {
8
+ books: Book[];
9
+ }
10
+
11
+ const { books } = Astro.props;
12
+ const featured = books.find((book) => book.featured || book.note || book.review) ?? books[0];
13
+ const readingList = books.filter((book) => book.recommended).slice(0, 4);
14
+ ---
15
+
16
+ <main class="book-sections">
17
+ <BookGallery books={books} />
18
+ <div class="book-secondary-grid">
19
+ <FeaturedNote book={featured} />
20
+ <ReadingList books={readingList.length ? readingList : books.slice(1, 5)} />
21
+ </div>
22
+ </main>
@@ -0,0 +1,44 @@
1
+ ---
2
+ import type { Book } from 'astro-book-bridge';
3
+
4
+ interface Props {
5
+ books: Book[];
6
+ }
7
+
8
+ const { books } = Astro.props;
9
+ const selected = books.find((book) => book.featured || book.description) ?? books[0];
10
+ ---
11
+
12
+ <main class="dark-shelf-sections">
13
+ <section class="dark-library" aria-labelledby="dark-library-heading">
14
+ <div class="dark-heading">
15
+ <div>
16
+ <p>Personal library</p>
17
+ <h1 id="dark-library-heading">All books</h1>
18
+ </div>
19
+ <span>{books.length} books</span>
20
+ </div>
21
+ <div class="dark-shelf-grid">
22
+ {books.map((book) => (
23
+ <article>
24
+ {book.imageUrl && <img src={book.imageUrl} alt={`Cover of ${book.title}`} loading="lazy" />}
25
+ <h2>{book.title}</h2>
26
+ <p>{book.author ?? 'Unknown author'}</p>
27
+ </article>
28
+ ))}
29
+ </div>
30
+ </section>
31
+
32
+ {selected && (
33
+ <aside class="dark-book-note" aria-labelledby="dark-book-note-heading">
34
+ {selected.imageUrl && <img src={selected.imageUrl} alt={`Cover of ${selected.title}`} />}
35
+ <div>
36
+ <p class="dark-state">Selected book</p>
37
+ <h2 id="dark-book-note-heading">{selected.title}</h2>
38
+ <p class="dark-author">{selected.author ?? 'Unknown author'}</p>
39
+ <p class="dark-description">{selected.note ?? selected.review ?? selected.description ?? 'Add a summary or a note from your own reading here.'}</p>
40
+ {selected.tags?.length ? <p class="dark-tags">{selected.tags.join(' · ')}</p> : <p class="dark-tags">{selected.sources.join(' · ')}</p>}
41
+ </div>
42
+ </aside>
43
+ )}
44
+ </main>
@@ -0,0 +1,45 @@
1
+ ---
2
+ import type { Book } from 'astro-book-bridge';
3
+
4
+ interface Props {
5
+ books: Book[];
6
+ }
7
+
8
+ const { books } = Astro.props;
9
+ const featured = books.find((book) => book.featured || book.review || book.note) ?? books[0];
10
+ const recent = books.filter((book) => book !== featured).slice(0, 6);
11
+ ---
12
+
13
+ <main class="editorial-book-sections">
14
+ {featured && (
15
+ <section class="editorial-feature" aria-labelledby="editorial-feature-heading">
16
+ <div class="editorial-intro">
17
+ <p class="editorial-kicker">Reading journal</p>
18
+ <h1 id="editorial-feature-heading">{featured.title}</h1>
19
+ <p class="editorial-author">{featured.author ?? 'From your library'}</p>
20
+ <p class="editorial-note">{featured.review ?? featured.note ?? featured.description ?? 'A place for a considered note about the book that is sitting with you.'}</p>
21
+ <p class="editorial-source">Sources: {featured.sources.join(' · ')}</p>
22
+ </div>
23
+ {featured.imageUrl && <img class="editorial-cover" src={featured.imageUrl} alt={`Cover of ${featured.title}`} />}
24
+ </section>
25
+ )}
26
+
27
+ <section class="editorial-recent" aria-labelledby="editorial-recent-heading">
28
+ <div class="editorial-section-heading">
29
+ <p class="editorial-kicker">From the shelf</p>
30
+ <h2 id="editorial-recent-heading">Recently read</h2>
31
+ </div>
32
+ <ol>
33
+ {recent.map((book) => (
34
+ <li>
35
+ {book.imageUrl && <img src={book.imageUrl} alt="" loading="lazy" />}
36
+ <div>
37
+ <h3>{book.title}</h3>
38
+ <p>{book.author ?? 'Unknown author'}</p>
39
+ </div>
40
+ <p class="editorial-date">{book.readAt ?? book.publishedDate ?? 'On the shelf'}</p>
41
+ </li>
42
+ ))}
43
+ </ol>
44
+ </section>
45
+ </main>
@@ -0,0 +1,24 @@
1
+ ---
2
+ import type { Book } from 'astro-book-bridge';
3
+
4
+ interface Props {
5
+ book?: Book;
6
+ }
7
+
8
+ const { book } = Astro.props;
9
+ ---
10
+
11
+ {book && (
12
+ <section class="featured-note" aria-labelledby="featured-heading">
13
+ <div class="featured-label">Featured note</div>
14
+ <div class="featured-content">
15
+ {book.imageUrl && <img src={book.imageUrl} alt={`Cover of ${book.title}`} loading="lazy" />}
16
+ <div>
17
+ <p class="eyebrow">{book.author ?? 'From your shelf'}</p>
18
+ <h2 id="featured-heading">{book.title}</h2>
19
+ <p class="featured-copy">{book.review ?? book.note ?? book.description ?? 'Leave a small note here: the line, idea, or feeling that made this book stay with you.'}</p>
20
+ {book.tags?.length ? <p class="tag-list">{book.tags.join(' · ')}</p> : <p class="tag-list">Personal pick · {book.sources.join(' + ')}</p>}
21
+ </div>
22
+ </div>
23
+ </section>
24
+ )}
@@ -0,0 +1,31 @@
1
+ ---
2
+ import type { Book } from 'astro-book-bridge';
3
+
4
+ interface Props {
5
+ books: Book[];
6
+ }
7
+
8
+ const { books } = Astro.props;
9
+ ---
10
+
11
+ <section class="reading-list" aria-labelledby="reading-list-heading">
12
+ <div class="section-heading">
13
+ <div>
14
+ <p class="eyebrow">Keep nearby</p>
15
+ <h2 id="reading-list-heading">Reading list</h2>
16
+ </div>
17
+ <span class="book-count">{books.length} saved</span>
18
+ </div>
19
+ <ol>
20
+ {books.map((book) => (
21
+ <li>
22
+ {book.imageUrl && <img src={book.imageUrl} alt="" loading="lazy" />}
23
+ <div>
24
+ <h3>{book.title}</h3>
25
+ <p>{book.author ?? 'Unknown author'}</p>
26
+ </div>
27
+ <span class="save-mark">Saved</span>
28
+ </li>
29
+ ))}
30
+ </ol>
31
+ </section>
@@ -0,0 +1,8 @@
1
+ ---
2
+ import { books } from 'astro-book-bridge:catalog';
3
+ import DarkShelfSections from '../components/DarkShelfSections.astro';
4
+ import '../styles/dark-shelf.css';
5
+ ---
6
+
7
+ <!doctype html>
8
+ <html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width" /><title>Dark shelf book sections</title></head><body><DarkShelfSections books={books} /></body></html>
@@ -0,0 +1,8 @@
1
+ ---
2
+ import { books } from 'astro-book-bridge:catalog';
3
+ import EditorialSections from '../components/EditorialSections.astro';
4
+ import '../styles/editorial.css';
5
+ ---
6
+
7
+ <!doctype html>
8
+ <html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width" /><title>Editorial book sections</title></head><body><EditorialSections books={books} /></body></html>
@@ -0,0 +1,18 @@
1
+ ---
2
+ import { books } from 'astro-book-bridge:catalog';
3
+ import BookSections from '../components/BookSections.astro';
4
+ import '../styles/demo.css';
5
+ ---
6
+
7
+ <!doctype html>
8
+ <html lang="en">
9
+ <head>
10
+ <meta charset="UTF-8" />
11
+ <meta name="viewport" content="width=device-width" />
12
+ <meta name="description" content="A copy-ready book catalog section for Astro." />
13
+ <title>Book sections demo</title>
14
+ </head>
15
+ <body>
16
+ <BookSections books={books} />
17
+ </body>
18
+ </html>
@@ -0,0 +1,22 @@
1
+ :root { color: #e8eefb; background: #061121; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
2
+ * { box-sizing: border-box; }
3
+ body { margin: 0; }
4
+ img { display: block; max-width: 100%; }
5
+ .dark-shelf-sections { width: min(1280px, calc(100% - 48px)); margin: 0 auto; padding: clamp(44px, 8vw, 108px) 0; }
6
+ .dark-heading { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 34px; }
7
+ .dark-heading p, .dark-state { margin: 0 0 8px; color: #75a9ff; font-size: .74rem; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; }
8
+ .dark-heading h1 { margin: 0; font-size: clamp(2.6rem, 6vw, 5.4rem); letter-spacing: -.065em; line-height: .88; }
9
+ .dark-heading > span { color: #9eacc2; font-size: .9rem; }
10
+ .dark-shelf-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 16px; }
11
+ .dark-shelf-grid article { min-width: 0; }
12
+ .dark-shelf-grid img { width: 100%; aspect-ratio: 2 / 3; object-fit: cover; border: 1px solid #203457; border-radius: 5px; }
13
+ .dark-shelf-grid h2 { margin: 10px 0 4px; font-size: .92rem; letter-spacing: -.02em; line-height: 1.2; }
14
+ .dark-shelf-grid p { margin: 0; color: #9eacc2; font-size: .78rem; }
15
+ .dark-book-note { display: grid; grid-template-columns: 132px 1fr; gap: 26px; margin-top: 68px; padding: clamp(22px, 4vw, 42px); border: 1px solid #203457; background: #0b1a30; }
16
+ .dark-book-note > img { width: 132px; aspect-ratio: 2 / 3; object-fit: cover; }
17
+ .dark-book-note h2 { margin: 0 0 8px; font-size: clamp(1.8rem, 4vw, 3.5rem); letter-spacing: -.055em; line-height: .94; }
18
+ .dark-author { margin: 0 0 22px; color: #9eacc2; }
19
+ .dark-description { max-width: 54ch; margin: 0; color: #d1d9e8; line-height: 1.62; }
20
+ .dark-tags { margin: 24px 0 0; color: #75a9ff; font-size: .8rem; font-weight: 700; }
21
+ @media (min-width: 700px) { .dark-shelf-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
22
+ @media (max-width: 560px) { .dark-shelf-sections { width: min(100% - 32px, 1280px); } .dark-book-note { grid-template-columns: 92px 1fr; gap: 18px; } .dark-book-note > img { width: 92px; } }
@@ -0,0 +1,64 @@
1
+ :root {
2
+ --paper: #fffdf8;
3
+ --ink: #181713;
4
+ --blue: #1746d0;
5
+ --red: #ef4a2f;
6
+ --yellow: #f8bd14;
7
+ --lilac: #bca4e8;
8
+ --line: #e6e0d7;
9
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
10
+ color: var(--ink);
11
+ background: var(--paper);
12
+ }
13
+
14
+ * { box-sizing: border-box; }
15
+ body { margin: 0; }
16
+ img { display: block; max-width: 100%; }
17
+
18
+ .book-sections { width: min(1200px, calc(100% - 48px)); margin: 0 auto; padding: clamp(44px, 8vw, 112px) 0; }
19
+ .section-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
20
+ .eyebrow { margin: 0 0 8px; color: var(--blue); font-size: .72rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
21
+ h2, h3, p { margin-top: 0; }
22
+ h2 { margin-bottom: 0; font-size: clamp(2rem, 5vw, 4.4rem); letter-spacing: -.065em; line-height: .94; }
23
+ h3 { font-size: 1rem; letter-spacing: -.025em; line-height: 1.15; }
24
+ .book-count { color: #6c6860; font-size: .88rem; white-space: nowrap; }
25
+ .book-filters { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 42px; }
26
+ .filter { border: 1px solid var(--line); border-radius: 99px; padding: 9px 16px; font-size: .9rem; }
27
+ .filter.is-active { border-color: var(--blue); background: var(--blue); color: white; }
28
+ .book-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 42px 24px; }
29
+ .book-card { min-width: 0; }
30
+ .book-card--offset { transform: translateY(28px); }
31
+ .book-cover { aspect-ratio: 2 / 3; width: 100%; border-radius: 4px; object-fit: cover; box-shadow: 0 10px 20px rgb(35 29 18 / .14); }
32
+ .book-no-cover { display: grid; aspect-ratio: 2 / 3; place-items: center; margin: 0; border: 1px solid var(--line); color: #6c6860; font-size: .88rem; text-align: center; }
33
+ .book-copy { padding: 14px 4px 0; }
34
+ .book-copy h3 { margin-bottom: 5px; }
35
+ .book-copy p { margin-bottom: 0; color: #625d55; font-size: .88rem; }
36
+ .book-copy .book-note { margin-top: 8px; color: var(--red); font-style: italic; }
37
+ .book-secondary-grid { display: grid; grid-template-columns: 1.2fr .8fr; gap: clamp(48px, 8vw, 112px); align-items: start; margin-top: 110px; }
38
+ .featured-label { display: inline-block; margin-bottom: 14px; padding-bottom: 4px; border-bottom: 3px solid var(--red); color: var(--red); font-weight: 800; font-size: 1.05rem; transform: rotate(-2deg); }
39
+ .featured-content { display: grid; grid-template-columns: minmax(120px, 34%) 1fr; gap: 24px; padding: clamp(20px, 4vw, 36px); background: #fff1ed; }
40
+ .featured-content > img { width: 100%; aspect-ratio: 2 / 3; object-fit: cover; box-shadow: 0 8px 18px rgb(65 31 23 / .18); }
41
+ .featured-content h2 { font-size: clamp(1.75rem, 3vw, 3.1rem); margin-bottom: 16px; }
42
+ .featured-copy { color: #423d37; font-size: .98rem; line-height: 1.62; }
43
+ .tag-list { margin-bottom: 0; color: var(--blue); font-size: .82rem; font-weight: 700; }
44
+ .reading-list h2 { font-size: clamp(1.75rem, 3vw, 3rem); }
45
+ .reading-list ol { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
46
+ .reading-list li { display: grid; grid-template-columns: 46px 1fr auto; align-items: center; gap: 14px; padding: 13px 0; border-bottom: 1px solid var(--line); }
47
+ .reading-list li img { width: 46px; height: 62px; object-fit: cover; }
48
+ .reading-list h3 { margin-bottom: 4px; font-size: .92rem; }
49
+ .reading-list p { margin-bottom: 0; color: #6c6860; font-size: .82rem; }
50
+ .save-mark { color: var(--blue); font-size: .72rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
51
+
52
+ @media (min-width: 760px) {
53
+ .book-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); }
54
+ .book-card--offset { transform: translateY(30px); }
55
+ }
56
+
57
+ @media (max-width: 700px) {
58
+ .book-sections { width: min(100% - 32px, 1200px); }
59
+ .section-heading { align-items: start; flex-direction: column; gap: 10px; }
60
+ .book-grid { gap: 30px 14px; }
61
+ .book-card--offset { transform: none; }
62
+ .book-secondary-grid { grid-template-columns: 1fr; margin-top: 64px; }
63
+ .featured-content { grid-template-columns: 100px 1fr; gap: 16px; }
64
+ }
@@ -0,0 +1,21 @@
1
+ :root { color: #28241f; background: #f6f1e7; font-family: Georgia, "Times New Roman", serif; }
2
+ * { box-sizing: border-box; }
3
+ body { margin: 0; }
4
+ img { display: block; max-width: 100%; }
5
+ .editorial-book-sections { width: min(1160px, calc(100% - 48px)); margin: 0 auto; padding: clamp(48px, 8vw, 110px) 0; }
6
+ .editorial-feature { display: grid; grid-template-columns: 1fr minmax(240px, 360px); gap: clamp(38px, 8vw, 100px); align-items: center; padding-bottom: clamp(64px, 10vw, 130px); }
7
+ .editorial-kicker { margin: 0 0 18px; color: #8b2830; font: 700 .72rem/1 ui-sans-serif, system-ui, sans-serif; letter-spacing: .14em; text-transform: uppercase; }
8
+ .editorial-feature h1 { max-width: 9ch; margin: 0 0 16px; font-size: clamp(3.2rem, 8vw, 7rem); font-weight: 500; letter-spacing: -.075em; line-height: .83; }
9
+ .editorial-author { margin: 0 0 30px; color: #6b6258; font-style: italic; font-size: 1.1rem; }
10
+ .editorial-note { max-width: 40ch; font-size: 1.08rem; line-height: 1.65; }
11
+ .editorial-source { margin: 24px 0 0; color: #8b2830; font: 700 .75rem/1.3 ui-sans-serif, system-ui, sans-serif; letter-spacing: .08em; text-transform: uppercase; }
12
+ .editorial-cover { width: 100%; aspect-ratio: 2 / 3; object-fit: cover; box-shadow: 12px 16px 0 #d7c5a3; }
13
+ .editorial-section-heading { display: flex; align-items: end; justify-content: space-between; border-bottom: 1px solid #cfc3b1; padding-bottom: 18px; }
14
+ .editorial-section-heading h2 { margin: 0; font-size: clamp(2rem, 4vw, 3.8rem); font-weight: 500; letter-spacing: -.05em; }
15
+ .editorial-recent ol { margin: 0; padding: 0; list-style: none; }
16
+ .editorial-recent li { display: grid; grid-template-columns: 52px 1fr minmax(110px, 180px); align-items: center; gap: 20px; padding: 17px 0; border-bottom: 1px solid #d9cfbf; }
17
+ .editorial-recent li img { width: 52px; height: 70px; object-fit: cover; }
18
+ .editorial-recent h3 { margin: 0 0 5px; font-size: 1.12rem; font-weight: 500; }
19
+ .editorial-recent li p { margin: 0; color: #6b6258; font-size: .9rem; }
20
+ .editorial-date { text-align: right; font: .75rem/1.3 ui-sans-serif, system-ui, sans-serif; }
21
+ @media (max-width: 640px) { .editorial-book-sections { width: min(100% - 32px, 1160px); } .editorial-feature { grid-template-columns: 1fr; } .editorial-cover { max-width: 240px; } .editorial-recent li { grid-template-columns: 44px 1fr; gap: 14px; } .editorial-recent li img { width: 44px; height: 60px; } .editorial-date { display: none; } }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "astro-book-bridge",
3
+ "version": "0.2.0",
4
+ "packageManager": "pnpm@12.4.2",
5
+ "description": "Build a typed, locally enriched Astro book catalog from Goodreads RSS, Open Library and Google Books.",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "examples/demo/src",
18
+ "examples/demo/astro.config.mjs",
19
+ "examples/demo/package.json"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json",
23
+ "check": "tsc --noEmit -p tsconfig.json",
24
+ "test": "vitest run",
25
+ "dev:demo": "pnpm --dir examples/demo dev",
26
+ "build:demo": "pnpm --dir examples/demo build",
27
+ "check:demo": "pnpm --dir examples/demo check",
28
+ "changeset": "changeset",
29
+ "version-packages": "changeset version",
30
+ "release": "pnpm build && changeset publish"
31
+ },
32
+ "keywords": [
33
+ "astro",
34
+ "goodreads",
35
+ "open-library",
36
+ "google-books",
37
+ "rss",
38
+ "books"
39
+ ],
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/MrGKanev/astro-books-bridge.git"
44
+ },
45
+ "homepage": "https://github.com/MrGKanev/astro-books-bridge#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/MrGKanev/astro-books-bridge/issues"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "peerDependencies": {
53
+ "astro": ">=4.0.0"
54
+ },
55
+ "dependencies": {
56
+ "fast-xml-parser": "^5.2.5",
57
+ "yaml": "^2.9.0",
58
+ "zod": "^3.24.2"
59
+ },
60
+ "devDependencies": {
61
+ "@astrojs/check": "^0.9.9",
62
+ "@changesets/cli": "^2.31.1",
63
+ "@types/node": "^26.1.1",
64
+ "astro": "^7.1.3",
65
+ "typescript": "^5.8.3",
66
+ "vite": "^8.1.5",
67
+ "vitest": "^4.1.10"
68
+ }
69
+ }