astro-book-bridge 0.2.0 → 0.2.1

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.
@@ -0,0 +1,70 @@
1
+ import { type BookCatalog } from './schema.js';
2
+ import { type ResolvedCoverOptions } from './covers.js';
3
+ export interface MetadataSourceOptions {
4
+ /** ISBNs to import even when no Goodreads feed is configured. */
5
+ isbns?: string[];
6
+ /** Enrich books already fetched from other sources. Defaults to true. */
7
+ enrich?: boolean;
8
+ }
9
+ export interface GoogleBooksSourceOptions extends MetadataSourceOptions {
10
+ /** Optional Google Books API key; useful for quota management. */
11
+ apiKey?: string;
12
+ }
13
+ export interface CoverOptions {
14
+ /** Keep provider URLs (default) or download reusable copies into public/. */
15
+ mode?: 'remote' | 'local';
16
+ /** Directory for local covers, relative to project root. Defaults to public/book-covers. */
17
+ directory?: string;
18
+ /** Image URL used when a provider has no cover or a download fails. */
19
+ fallbackUrl?: string;
20
+ }
21
+ export interface BookBridgeOptions {
22
+ /** Optional Goodreads shelf RSS URL, for example https://www.goodreads.com/review/list_rss/USER_ID?shelf=read */
23
+ rssUrl?: string;
24
+ /** Open Library metadata. `true` enriches ISBNs from the configured feed. */
25
+ openLibrary?: boolean | MetadataSourceOptions;
26
+ /** Google Books metadata. `true` enriches ISBNs from the configured feed. */
27
+ googleBooks?: boolean | GoogleBooksSourceOptions;
28
+ /** JSON overrides relative to the Astro project root. Missing file means no overrides. */
29
+ overrides?: string;
30
+ /** Optional directory of Markdown or MDX review overrides. */
31
+ markdownOverrides?: string;
32
+ /** Cache directory relative to the Astro project root. Set false to disable cache. */
33
+ cache?: string | false;
34
+ /** Remote or local cover behaviour. */
35
+ covers?: CoverOptions;
36
+ /** Optional JSON catalog written after a successful build, relative to project root. */
37
+ output?: string;
38
+ /** Request timeout in milliseconds. Defaults to 10 seconds. */
39
+ timeoutMs?: number;
40
+ /** Use the last successful RSS response after a fetch failure. Defaults to true. */
41
+ staleIfError?: boolean;
42
+ /** Warn (default) or stop the build when sources disagree on a title for the same ID. */
43
+ conflicts?: 'warn' | 'error';
44
+ }
45
+ /** @deprecated Use BookBridgeOptions. */
46
+ export type GoodreadsBridgeOptions = BookBridgeOptions;
47
+ export interface ResolvedBookBridgeOptions {
48
+ rssUrl?: string;
49
+ openLibrary?: Required<MetadataSourceOptions>;
50
+ googleBooks?: Required<MetadataSourceOptions> & {
51
+ apiKey?: string;
52
+ };
53
+ overrides: string;
54
+ markdownOverrides: string;
55
+ cache: string | false;
56
+ covers: ResolvedCoverOptions;
57
+ output?: string;
58
+ timeoutMs: number;
59
+ staleIfError: boolean;
60
+ conflicts: 'warn' | 'error';
61
+ root: string;
62
+ }
63
+ /** @deprecated Use ResolvedBookBridgeOptions. */
64
+ export type ResolvedGoodreadsBridgeOptions = ResolvedBookBridgeOptions;
65
+ export interface CatalogLogger {
66
+ warn(message: string): void;
67
+ }
68
+ export declare function resolveOptions(options: BookBridgeOptions, root: string): ResolvedBookBridgeOptions;
69
+ /** Fetches, enriches and optionally persists a catalog for the virtual module. */
70
+ export declare function buildCatalog(options: ResolvedBookBridgeOptions, logger?: CatalogLogger): Promise<BookCatalog>;
@@ -0,0 +1,184 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
3
+ import { parseGoodreadsRss } from './rss.js';
4
+ import { combineSourceBooks, mergeBooks } from './merge.js';
5
+ import { fetchGoogleBooks, fetchOpenLibraryBooks } from './providers.js';
6
+ import { bookOverridesSchema } from './schema.js';
7
+ import { assertValidIsbn } from './identifiers.js';
8
+ import { loadMarkdownOverrides } from './markdown.js';
9
+ import { applyCoverPolicy } from './covers.js';
10
+ export function resolveOptions(options, root) {
11
+ const sourceOptions = (source) => {
12
+ if (!source)
13
+ return undefined;
14
+ return { isbns: source === true ? [] : source.isbns ?? [], enrich: source === true ? true : source.enrich ?? true };
15
+ };
16
+ const openLibrary = sourceOptions(options.openLibrary);
17
+ const googleBooks = options.googleBooks
18
+ ? { ...sourceOptions(options.googleBooks), apiKey: options.googleBooks === true ? undefined : options.googleBooks.apiKey }
19
+ : undefined;
20
+ const hasDirectIsbns = Boolean(openLibrary?.isbns.length || googleBooks?.isbns.length);
21
+ for (const value of [...(openLibrary?.isbns ?? []), ...(googleBooks?.isbns ?? [])])
22
+ assertValidIsbn(value, 'Configured ISBN');
23
+ if (!options.rssUrl?.trim() && !hasDirectIsbns) {
24
+ throw new Error('[astro-book-bridge] Configure rssUrl or provide ISBNs to openLibrary/googleBooks.');
25
+ }
26
+ return {
27
+ rssUrl: options.rssUrl?.trim() || undefined,
28
+ openLibrary,
29
+ googleBooks,
30
+ root,
31
+ overrides: options.overrides ?? 'src/content/book-overrides.json',
32
+ markdownOverrides: options.markdownOverrides ?? 'src/content/book-overrides',
33
+ cache: options.cache === false ? false : options.cache ?? '.astro/book-bridge',
34
+ covers: {
35
+ mode: options.covers?.mode ?? 'remote',
36
+ directory: options.covers?.directory ?? 'public/book-covers',
37
+ fallbackUrl: options.covers?.fallbackUrl,
38
+ },
39
+ output: options.output,
40
+ timeoutMs: options.timeoutMs ?? 10_000,
41
+ staleIfError: options.staleIfError ?? true,
42
+ conflicts: options.conflicts ?? 'warn',
43
+ };
44
+ }
45
+ function atRoot(root, target) {
46
+ return isAbsolute(target) ? target : resolve(root, target);
47
+ }
48
+ function providerCachePath(cache, provider) {
49
+ if (!cache)
50
+ return undefined;
51
+ if (extname(cache) === '.json') {
52
+ return provider === 'goodreads-rss' ? cache : `${cache.slice(0, -5)}.${provider}.json`;
53
+ }
54
+ return join(cache, `${provider}.json`);
55
+ }
56
+ async function readCache(path, key) {
57
+ try {
58
+ const value = JSON.parse(await readFile(path, 'utf8'));
59
+ return value.key === key && value.data !== undefined && typeof value.fetchedAt === 'string' ? value : undefined;
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ async function writeJson(path, value) {
66
+ await mkdir(dirname(path), { recursive: true });
67
+ const temporary = `${path}.tmp`;
68
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
69
+ await rename(temporary, path);
70
+ }
71
+ async function fetchWithCache(label, path, key, load, options, logger) {
72
+ try {
73
+ const data = await load();
74
+ if (path)
75
+ await writeJson(path, { data, key, fetchedAt: new Date().toISOString() });
76
+ return { data, usedCache: false };
77
+ }
78
+ catch (error) {
79
+ const cached = path ? await readCache(path, key) : undefined;
80
+ if (options.staleIfError && cached) {
81
+ logger?.warn(`[astro-book-bridge] ${label} fetch failed; using cached data from ${cached.fetchedAt}.`);
82
+ return { data: cached.data, usedCache: true };
83
+ }
84
+ throw error;
85
+ }
86
+ }
87
+ async function fetchRss(url, timeoutMs) {
88
+ const controller = new AbortController();
89
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
90
+ try {
91
+ const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/rss+xml, application/xml, text/xml' } });
92
+ if (!response.ok)
93
+ throw new Error(`RSS returned HTTP ${response.status}.`);
94
+ return await response.text();
95
+ }
96
+ finally {
97
+ clearTimeout(timer);
98
+ }
99
+ }
100
+ async function loadOverrides(path) {
101
+ try {
102
+ return bookOverridesSchema.parse(JSON.parse(await readFile(path, 'utf8')));
103
+ }
104
+ catch (error) {
105
+ if (error.code === 'ENOENT')
106
+ return { books: [] };
107
+ throw new Error(`[astro-book-bridge] Invalid overrides file (${path}): ${error instanceof Error ? error.message : String(error)}`);
108
+ }
109
+ }
110
+ /** Fetches, enriches and optionally persists a catalog for the virtual module. */
111
+ export async function buildCatalog(options, logger) {
112
+ const cacheDirectory = options.cache ? atRoot(options.root, options.cache) : undefined;
113
+ const remoteBooks = [];
114
+ let usedCache = false;
115
+ if (options.rssUrl) {
116
+ try {
117
+ const result = await fetchWithCache('RSS', providerCachePath(cacheDirectory, 'goodreads-rss'), options.rssUrl, () => fetchRss(options.rssUrl, options.timeoutMs), options, logger);
118
+ remoteBooks.push(...parseGoodreadsRss(result.data));
119
+ usedCache ||= result.usedCache;
120
+ }
121
+ catch (error) {
122
+ if (!options.openLibrary && !options.googleBooks) {
123
+ throw new Error(`[astro-book-bridge] Could not fetch RSS: ${error instanceof Error ? error.message : String(error)}`);
124
+ }
125
+ else {
126
+ logger?.warn(`[astro-book-bridge] RSS fetch failed: ${error instanceof Error ? error.message : String(error)}`);
127
+ }
128
+ }
129
+ }
130
+ const sourceIsbns = remoteBooks.flatMap((book) => [book.isbn13, book.isbn].flatMap((value) => {
131
+ if (!value)
132
+ return [];
133
+ try {
134
+ return [assertValidIsbn(value, `RSS ISBN for "${book.title}"`)];
135
+ }
136
+ catch (error) {
137
+ logger?.warn(`[astro-book-bridge] ${error instanceof Error ? error.message : String(error)} Skipping metadata enrichment for this identifier.`);
138
+ return [];
139
+ }
140
+ }));
141
+ const sourceIsbnsFor = (source) => [...new Set([...source.isbns, ...(source.enrich ? sourceIsbns : [])])];
142
+ if (options.openLibrary) {
143
+ try {
144
+ const identifiers = sourceIsbnsFor(options.openLibrary).sort();
145
+ const result = await fetchWithCache('Open Library', providerCachePath(cacheDirectory, 'open-library'), identifiers.join(','), () => fetchOpenLibraryBooks(identifiers, options.timeoutMs), options, logger);
146
+ remoteBooks.push(...result.data);
147
+ usedCache ||= result.usedCache;
148
+ }
149
+ catch (error) {
150
+ logger?.warn(`[astro-book-bridge] Open Library fetch failed: ${error instanceof Error ? error.message : String(error)}`);
151
+ }
152
+ }
153
+ const googleBooks = options.googleBooks;
154
+ if (googleBooks) {
155
+ try {
156
+ const identifiers = sourceIsbnsFor(googleBooks).sort();
157
+ const result = await fetchWithCache('Google Books', providerCachePath(cacheDirectory, 'google-books'), identifiers.join(','), () => fetchGoogleBooks(identifiers, options.timeoutMs, googleBooks.apiKey), options, logger);
158
+ remoteBooks.push(...result.data);
159
+ usedCache ||= result.usedCache;
160
+ }
161
+ catch (error) {
162
+ logger?.warn(`[astro-book-bridge] Google Books fetch failed: ${error instanceof Error ? error.message : String(error)}`);
163
+ }
164
+ }
165
+ if (remoteBooks.length === 0) {
166
+ throw new Error('[astro-book-bridge] No books could be fetched from the configured sources.');
167
+ }
168
+ const overrides = await loadOverrides(atRoot(options.root, options.overrides));
169
+ const markdownOverrides = await loadMarkdownOverrides(atRoot(options.root, options.markdownOverrides));
170
+ const combinedBooks = combineSourceBooks(remoteBooks, (message) => {
171
+ if (options.conflicts === 'error')
172
+ throw new Error(`[astro-book-bridge] ${message}`);
173
+ logger?.warn(`[astro-book-bridge] ${message}`);
174
+ });
175
+ const books = await applyCoverPolicy(mergeBooks(combinedBooks, [...overrides.books, ...markdownOverrides]), options.root, options.covers, options.timeoutMs, (message) => logger?.warn(message));
176
+ const catalog = {
177
+ books,
178
+ generatedAt: new Date().toISOString(),
179
+ source: { rssUrl: options.rssUrl, usedCache, providers: [...new Set(combinedBooks.map((book) => book.source ?? 'goodreads-rss'))] },
180
+ };
181
+ if (options.output)
182
+ await writeJson(atRoot(options.root, options.output), catalog);
183
+ return catalog;
184
+ }
@@ -0,0 +1,8 @@
1
+ import type { Book } from './schema.js';
2
+ export interface ResolvedCoverOptions {
3
+ mode: 'remote' | 'local';
4
+ directory: string;
5
+ fallbackUrl?: string;
6
+ }
7
+ /** Stores remote covers in public/ while retaining source attribution for templates. */
8
+ export declare function applyCoverPolicy(books: Book[], root: string, options: ResolvedCoverOptions, timeoutMs: number, warn: (message: string) => void): Promise<Book[]>;
package/dist/covers.js ADDED
@@ -0,0 +1,79 @@
1
+ import { access, mkdir, writeFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { relative, resolve } from 'node:path';
4
+ const extensionsByType = {
5
+ 'image/avif': 'avif',
6
+ 'image/jpeg': 'jpg',
7
+ 'image/png': 'png',
8
+ 'image/webp': 'webp',
9
+ };
10
+ function localUrl(root, directory, file) {
11
+ const publicDirectory = resolve(root, 'public');
12
+ const path = resolve(root, directory, file);
13
+ const pathFromPublic = relative(publicDirectory, path);
14
+ if (pathFromPublic.startsWith('..')) {
15
+ throw new Error('[astro-book-bridge] covers.directory must be inside the project public directory.');
16
+ }
17
+ return `/${pathFromPublic.split('\\').join('/')}`;
18
+ }
19
+ async function existingCover(directory, hash) {
20
+ for (const extension of Object.values(extensionsByType)) {
21
+ const path = resolve(directory, `${hash}.${extension}`);
22
+ try {
23
+ await access(path);
24
+ return path;
25
+ }
26
+ catch {
27
+ // Try the next common image extension.
28
+ }
29
+ }
30
+ return undefined;
31
+ }
32
+ async function cacheOneCover(book, root, options, timeoutMs) {
33
+ const originalUrl = book.coverSourceUrl ?? book.imageUrl;
34
+ if (!originalUrl)
35
+ return options.fallbackUrl ? { ...book, imageUrl: options.fallbackUrl } : book;
36
+ const hash = createHash('sha256').update(originalUrl).digest('hex').slice(0, 24);
37
+ const directory = resolve(root, options.directory);
38
+ const existing = await existingCover(directory, hash);
39
+ if (existing) {
40
+ const extension = existing.split('.').pop();
41
+ return { ...book, imageUrl: localUrl(root, options.directory, `${hash}.${extension}`), coverSourceUrl: originalUrl, coverAttribution: { provider: book.coverProvider ?? book.source, url: originalUrl } };
42
+ }
43
+ const controller = new AbortController();
44
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
45
+ try {
46
+ const response = await fetch(originalUrl, { signal: controller.signal, headers: { Accept: 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8' } });
47
+ if (!response.ok)
48
+ throw new Error(`returned HTTP ${response.status}`);
49
+ const contentType = response.headers.get('content-type')?.split(';')[0].toLowerCase() ?? '';
50
+ if (!contentType.startsWith('image/'))
51
+ throw new Error(`returned unexpected content type ${contentType || 'unknown'}`);
52
+ const extension = extensionsByType[contentType] ?? 'jpg';
53
+ await mkdir(directory, { recursive: true });
54
+ await writeFile(resolve(directory, `${hash}.${extension}`), Buffer.from(await response.arrayBuffer()));
55
+ return { ...book, imageUrl: localUrl(root, options.directory, `${hash}.${extension}`), coverSourceUrl: originalUrl, coverAttribution: { provider: book.coverProvider ?? book.source, url: originalUrl } };
56
+ }
57
+ finally {
58
+ clearTimeout(timer);
59
+ }
60
+ }
61
+ /** Stores remote covers in public/ while retaining source attribution for templates. */
62
+ export async function applyCoverPolicy(books, root, options, timeoutMs, warn) {
63
+ if (options.mode === 'remote') {
64
+ return books.map((book) => book.imageUrl ? { ...book, coverSourceUrl: book.coverSourceUrl ?? book.imageUrl, coverAttribution: { provider: book.coverProvider ?? book.source, url: book.coverSourceUrl ?? book.imageUrl } } : options.fallbackUrl ? { ...book, imageUrl: options.fallbackUrl } : book);
65
+ }
66
+ const result = [...books];
67
+ for (let start = 0; start < books.length; start += 4) {
68
+ await Promise.all(books.slice(start, start + 4).map(async (book, index) => {
69
+ try {
70
+ result[start + index] = await cacheOneCover(book, root, options, timeoutMs);
71
+ }
72
+ catch (error) {
73
+ warn(`[astro-book-bridge] Could not cache cover for "${book.title}": ${error instanceof Error ? error.message : String(error)}.`);
74
+ result[start + index] = options.fallbackUrl ? { ...book, imageUrl: options.fallbackUrl } : book;
75
+ }
76
+ }));
77
+ }
78
+ return result;
79
+ }
@@ -0,0 +1,6 @@
1
+ /** Removes presentation punctuation from an ISBN while retaining a possible X check digit. */
2
+ export declare function normalizeIsbn(value: string): string;
3
+ export declare function isValidIsbn10(value: string): boolean;
4
+ export declare function isValidIsbn13(value: string): boolean;
5
+ export declare function isValidIsbn(value: string): boolean;
6
+ export declare function assertValidIsbn(value: string, field?: string): string;
@@ -0,0 +1,27 @@
1
+ /** Removes presentation punctuation from an ISBN while retaining a possible X check digit. */
2
+ export function normalizeIsbn(value) {
3
+ return value.replace(/[^0-9Xx]/g, '').toUpperCase();
4
+ }
5
+ export function isValidIsbn10(value) {
6
+ const isbn = normalizeIsbn(value);
7
+ if (!/^\d{9}[\dX]$/.test(isbn))
8
+ return false;
9
+ const sum = [...isbn].reduce((total, digit, index) => total + (digit === 'X' ? 10 : Number(digit)) * (10 - index), 0);
10
+ return sum % 11 === 0;
11
+ }
12
+ export function isValidIsbn13(value) {
13
+ const isbn = normalizeIsbn(value);
14
+ if (!/^\d{13}$/.test(isbn))
15
+ return false;
16
+ const sum = [...isbn].slice(0, 12).reduce((total, digit, index) => total + Number(digit) * (index % 2 === 0 ? 1 : 3), 0);
17
+ return (10 - (sum % 10)) % 10 === Number(isbn[12]);
18
+ }
19
+ export function isValidIsbn(value) {
20
+ return isValidIsbn10(value) || isValidIsbn13(value);
21
+ }
22
+ export function assertValidIsbn(value, field = 'ISBN') {
23
+ const normalized = normalizeIsbn(value);
24
+ if (!isValidIsbn(normalized))
25
+ throw new Error(`${field} must be a valid ISBN-10 or ISBN-13: ${value}.`);
26
+ return normalized;
27
+ }
@@ -0,0 +1,13 @@
1
+ import type { AstroIntegration } from 'astro';
2
+ import { type BookBridgeOptions } from './catalog.js';
3
+ export type { Book, BookCatalog, BookOverride, BookSourceName, GoodreadsBook } from './schema.js';
4
+ export type { BookBridgeOptions, CoverOptions, GoogleBooksSourceOptions, GoodreadsBridgeOptions, MetadataSourceOptions } from './catalog.js';
5
+ export { buildCatalog } from './catalog.js';
6
+ export { parseGoodreadsRss } from './rss.js';
7
+ export { assertValidIsbn, isValidIsbn, isValidIsbn10, isValidIsbn13, normalizeIsbn } from './identifiers.js';
8
+ export declare const virtualModuleId = "astro-book-bridge:catalog";
9
+ /**
10
+ * Astro integration exposing a multi-source book catalog through
11
+ * `astro-book-bridge:catalog`.
12
+ */
13
+ export default function bookBridge(options: BookBridgeOptions): AstroIntegration;
package/dist/index.js ADDED
@@ -0,0 +1,71 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { resolve } from 'node:path';
3
+ import { buildCatalog, resolveOptions } from './catalog.js';
4
+ export { buildCatalog } from './catalog.js';
5
+ export { parseGoodreadsRss } from './rss.js';
6
+ export { assertValidIsbn, isValidIsbn, isValidIsbn10, isValidIsbn13, normalizeIsbn } from './identifiers.js';
7
+ export const virtualModuleId = 'astro-book-bridge:catalog';
8
+ const resolvedVirtualModuleId = `\0${virtualModuleId}`;
9
+ function virtualModule(options, logger) {
10
+ let server;
11
+ let catalog;
12
+ const invalidate = () => {
13
+ catalog = undefined;
14
+ const module = server?.moduleGraph.getModuleById(resolvedVirtualModuleId);
15
+ if (module)
16
+ server?.moduleGraph.invalidateModule(module);
17
+ server?.ws.send({ type: 'full-reload' });
18
+ };
19
+ return {
20
+ name: 'astro-book-bridge',
21
+ resolveId(id) {
22
+ return id === virtualModuleId ? resolvedVirtualModuleId : undefined;
23
+ },
24
+ async load(id) {
25
+ if (id !== resolvedVirtualModuleId)
26
+ return undefined;
27
+ catalog ??= buildCatalog(options, logger);
28
+ const result = await catalog;
29
+ return `export const catalog = ${JSON.stringify(result)};\nexport const books = catalog.books;\nexport default books;`;
30
+ },
31
+ configureServer(devServer) {
32
+ server = devServer;
33
+ devServer.watcher.add(resolve(options.root, options.overrides));
34
+ devServer.watcher.on('change', (path) => {
35
+ if (path === resolve(options.root, options.overrides))
36
+ invalidate();
37
+ });
38
+ },
39
+ };
40
+ }
41
+ /**
42
+ * Astro integration exposing a multi-source book catalog through
43
+ * `astro-book-bridge:catalog`.
44
+ */
45
+ export default function bookBridge(options) {
46
+ return {
47
+ name: 'astro-book-bridge',
48
+ hooks: {
49
+ 'astro:config:setup': ({ config, logger, updateConfig }) => {
50
+ const root = fileURLToPath(config.root);
51
+ const resolvedOptions = resolveOptions(options, root);
52
+ updateConfig({
53
+ vite: {
54
+ plugins: [virtualModule(resolvedOptions, logger)],
55
+ },
56
+ });
57
+ },
58
+ 'astro:config:done': ({ injectTypes }) => {
59
+ injectTypes({
60
+ filename: 'astro-book-bridge.d.ts',
61
+ content: `declare module '${virtualModuleId}' {
62
+ import type { Book, BookCatalog } from 'astro-book-bridge';
63
+ export const catalog: BookCatalog;
64
+ export const books: Book[];
65
+ export default books;
66
+ }`,
67
+ });
68
+ },
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,3 @@
1
+ import { type BookOverride } from './schema.js';
2
+ /** Loads one book override per Markdown/MDX file from a directory. */
3
+ export declare function loadMarkdownOverrides(directory: string): Promise<BookOverride[]>;
@@ -0,0 +1,47 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { extname, join } from 'node:path';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { bookOverrideSchema } from './schema.js';
5
+ async function markdownFiles(directory) {
6
+ try {
7
+ const entries = await readdir(directory, { withFileTypes: true });
8
+ const nested = await Promise.all(entries.map(async (entry) => {
9
+ const path = join(directory, entry.name);
10
+ if (entry.isDirectory())
11
+ return markdownFiles(path);
12
+ return ['.md', '.mdx'].includes(extname(entry.name).toLowerCase()) ? [path] : [];
13
+ }));
14
+ return nested.flat();
15
+ }
16
+ catch (error) {
17
+ if (error.code === 'ENOENT')
18
+ return [];
19
+ throw error;
20
+ }
21
+ }
22
+ function parseFrontmatter(contents, path) {
23
+ if (!contents.startsWith('---')) {
24
+ throw new Error(`Markdown override (${path}) must start with YAML frontmatter.`);
25
+ }
26
+ const closeIndex = contents.indexOf('\n---', 3);
27
+ if (closeIndex === -1)
28
+ throw new Error(`Markdown override (${path}) has unclosed frontmatter.`);
29
+ const frontmatter = contents.slice(3, closeIndex).trim();
30
+ const body = contents.slice(closeIndex + 4).trim();
31
+ const fields = parseYaml(frontmatter);
32
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
33
+ throw new Error(`Markdown override (${path}) frontmatter must be a YAML object.`);
34
+ }
35
+ return bookOverrideSchema.parse({ ...fields, review: fields.review ?? (body || undefined) });
36
+ }
37
+ /** Loads one book override per Markdown/MDX file from a directory. */
38
+ export async function loadMarkdownOverrides(directory) {
39
+ return Promise.all((await markdownFiles(directory)).sort().map(async (path) => {
40
+ try {
41
+ return parseFrontmatter(await readFile(path, 'utf8'), path);
42
+ }
43
+ catch (error) {
44
+ throw new Error(`[astro-book-bridge] Invalid Markdown override: ${error instanceof Error ? error.message : String(error)}`);
45
+ }
46
+ }));
47
+ }
@@ -0,0 +1,8 @@
1
+ import type { Book, BookOverride, GoodreadsBook } from './schema.js';
2
+ /** Goodreads ID always wins; ISBN13 and ISBN are used only as fallbacks. */
3
+ export declare function mergeBooks(remoteBooks: GoodreadsBook[], overrides: BookOverride[]): Book[];
4
+ /**
5
+ * Coalesces books returned by multiple providers. Provider order determines
6
+ * metadata priority: later providers only fill fields that are still missing.
7
+ */
8
+ export declare function combineSourceBooks(sourceBooks: GoodreadsBook[], onConflict?: (message: string) => void): GoodreadsBook[];
package/dist/merge.js ADDED
@@ -0,0 +1,79 @@
1
+ import { normalizeIsbn } from './identifiers.js';
2
+ function normalized(value) {
3
+ return value ? normalizeIsbn(value) : undefined;
4
+ }
5
+ function key(value) {
6
+ return normalized(value);
7
+ }
8
+ function uniqueIndex(overrides, field) {
9
+ const index = new Map();
10
+ for (const override of overrides) {
11
+ const value = field === 'goodreadsId' ? override[field] : key(override[field]);
12
+ if (!value)
13
+ continue;
14
+ if (index.has(value)) {
15
+ throw new Error(`Duplicate ${field} override key: ${value}.`);
16
+ }
17
+ index.set(value, override);
18
+ }
19
+ return index;
20
+ }
21
+ /** Goodreads ID always wins; ISBN13 and ISBN are used only as fallbacks. */
22
+ export function mergeBooks(remoteBooks, overrides) {
23
+ const byGoodreadsId = uniqueIndex(overrides, 'goodreadsId');
24
+ const byIsbn13 = uniqueIndex(overrides, 'isbn13');
25
+ const byIsbn = uniqueIndex(overrides, 'isbn');
26
+ return remoteBooks.map((remote) => {
27
+ const override = (remote.goodreadsId ? byGoodreadsId.get(remote.goodreadsId) : undefined) ??
28
+ (key(remote.isbn13) ? byIsbn13.get(key(remote.isbn13)) : undefined) ??
29
+ (key(remote.isbn) ? byIsbn.get(key(remote.isbn)) : undefined);
30
+ const { goodreadsId: _goodreadsId, isbn: _isbn, isbn13: _isbn13, ...local } = override ?? {};
31
+ return { ...remote, ...local, source: remote.source ?? 'goodreads-rss', sources: remote.sources ?? [remote.source ?? 'goodreads-rss'] };
32
+ });
33
+ }
34
+ function identifierKey(value) {
35
+ return value?.trim() || undefined;
36
+ }
37
+ /**
38
+ * Coalesces books returned by multiple providers. Provider order determines
39
+ * metadata priority: later providers only fill fields that are still missing.
40
+ */
41
+ export function combineSourceBooks(sourceBooks, onConflict) {
42
+ const books = [];
43
+ const byGoodreadsId = new Map();
44
+ const byIsbn13 = new Map();
45
+ const byIsbn = new Map();
46
+ for (const incoming of sourceBooks) {
47
+ const match = (identifierKey(incoming.goodreadsId) ? byGoodreadsId.get(identifierKey(incoming.goodreadsId)) : undefined) ??
48
+ (key(incoming.isbn13) ? byIsbn13.get(key(incoming.isbn13)) : undefined) ??
49
+ (key(incoming.isbn) ? byIsbn.get(key(incoming.isbn)) : undefined);
50
+ const book = match ?? { ...incoming, coverProvider: incoming.coverProvider ?? (incoming.imageUrl ? incoming.source : undefined), sources: [incoming.source ?? 'goodreads-rss'] };
51
+ if (match) {
52
+ const hadImage = Boolean(book.imageUrl);
53
+ if (match.title.localeCompare(incoming.title, undefined, { sensitivity: 'base' }) !== 0) {
54
+ const identifier = incoming.goodreadsId ?? incoming.isbn13 ?? incoming.isbn ?? 'unknown identifier';
55
+ onConflict?.(`Conflicting titles for ${identifier}: "${match.title}" (${match.source ?? 'unknown'}) and "${incoming.title}" (${incoming.source ?? 'unknown'}). Keeping the first source.`);
56
+ }
57
+ for (const [field, value] of Object.entries(incoming)) {
58
+ if (value !== undefined && book[field] === undefined) {
59
+ book[field] = value;
60
+ }
61
+ }
62
+ if (!hadImage && incoming.imageUrl)
63
+ book.coverProvider = incoming.coverProvider ?? incoming.source;
64
+ const source = incoming.source;
65
+ if (source && !book.sources?.includes(source))
66
+ book.sources = [...(book.sources ?? []), source];
67
+ }
68
+ else {
69
+ books.push(book);
70
+ }
71
+ if (book.goodreadsId)
72
+ byGoodreadsId.set(book.goodreadsId, book);
73
+ if (key(book.isbn13))
74
+ byIsbn13.set(key(book.isbn13), book);
75
+ if (key(book.isbn))
76
+ byIsbn.set(key(book.isbn), book);
77
+ }
78
+ return books;
79
+ }
@@ -0,0 +1,4 @@
1
+ import type { GoodreadsBook } from './schema.js';
2
+ export declare function fetchJson(url: string, timeoutMs: number): Promise<unknown>;
3
+ export declare function fetchOpenLibraryBooks(isbns: string[], timeoutMs: number): Promise<GoodreadsBook[]>;
4
+ export declare function fetchGoogleBooks(isbns: string[], timeoutMs: number, apiKey?: string): Promise<GoodreadsBook[]>;
@@ -0,0 +1,95 @@
1
+ import { normalizeIsbn } from './identifiers.js';
2
+ function text(value) {
3
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
4
+ }
5
+ function stringList(value) {
6
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string' && Boolean(item.trim())) : undefined;
7
+ }
8
+ function objectList(value) {
9
+ return Array.isArray(value) ? value.filter((item) => Boolean(item) && typeof item === 'object') : undefined;
10
+ }
11
+ export async function fetchJson(url, timeoutMs) {
12
+ const controller = new AbortController();
13
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
14
+ try {
15
+ const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } });
16
+ if (!response.ok)
17
+ throw new Error(`returned HTTP ${response.status}.`);
18
+ return await response.json();
19
+ }
20
+ finally {
21
+ clearTimeout(timer);
22
+ }
23
+ }
24
+ export async function fetchOpenLibraryBooks(isbns, timeoutMs) {
25
+ const identifiers = [...new Set(isbns.map(normalizeIsbn).filter(Boolean))];
26
+ if (identifiers.length === 0)
27
+ return [];
28
+ const url = new URL('https://openlibrary.org/api/books.json');
29
+ url.searchParams.set('bibkeys', identifiers.map((value) => `ISBN:${value}`).join(','));
30
+ url.searchParams.set('jscmd', 'data');
31
+ const response = await fetchJson(url.toString(), timeoutMs);
32
+ return Object.values(response).flatMap((entry) => {
33
+ if (!entry || typeof entry !== 'object')
34
+ return [];
35
+ const data = entry;
36
+ const title = text(data.title);
37
+ if (!title)
38
+ return [];
39
+ const identifiers = data.identifiers;
40
+ const cover = data.cover;
41
+ return [{
42
+ source: 'open-library',
43
+ title,
44
+ author: objectList(data.authors)?.map((author) => text(author.name)).filter(Boolean).join(', ') || undefined,
45
+ isbn: stringList(identifiers?.isbn_10)?.[0],
46
+ isbn13: stringList(identifiers?.isbn_13)?.[0],
47
+ link: text(data.url),
48
+ imageUrl: text(cover?.large) ?? text(cover?.medium) ?? text(cover?.small),
49
+ coverSourceUrl: text(cover?.large) ?? text(cover?.medium) ?? text(cover?.small),
50
+ coverProvider: text(cover?.large) ?? text(cover?.medium) ?? text(cover?.small) ? 'open-library' : undefined,
51
+ description: text(data.description),
52
+ publisher: objectList(data.publishers)?.map((publisher) => text(publisher.name)).filter(Boolean).join(', ') || undefined,
53
+ publishedDate: text(data.publish_date),
54
+ pageCount: typeof data.number_of_pages === 'number' ? data.number_of_pages : undefined,
55
+ subjects: objectList(data.subjects)?.map((subject) => text(subject.name)).filter((subject) => Boolean(subject)),
56
+ previewLink: objectList(data.ebooks)?.map((ebook) => text(ebook.preview_url)).find(Boolean),
57
+ }];
58
+ });
59
+ }
60
+ export async function fetchGoogleBooks(isbns, timeoutMs, apiKey) {
61
+ const books = await Promise.all([...new Set(isbns.map(normalizeIsbn).filter(Boolean))].map(async (value) => {
62
+ const url = new URL('https://www.googleapis.com/books/v1/volumes');
63
+ url.searchParams.set('q', `isbn:${value}`);
64
+ if (apiKey)
65
+ url.searchParams.set('key', apiKey);
66
+ const response = await fetchJson(url.toString(), timeoutMs);
67
+ const volume = response.items?.[0];
68
+ const info = volume?.volumeInfo;
69
+ const title = text(info?.title);
70
+ if (!title)
71
+ return undefined;
72
+ const industryIdentifiers = Array.isArray(info?.industryIdentifiers) ? info?.industryIdentifiers : [];
73
+ const cover = info?.imageLinks;
74
+ return {
75
+ source: 'google-books',
76
+ title,
77
+ author: stringList(info?.authors)?.join(', '),
78
+ isbn: text(industryIdentifiers.find((id) => id.type === 'ISBN_10')?.identifier),
79
+ isbn13: text(industryIdentifiers.find((id) => id.type === 'ISBN_13')?.identifier),
80
+ link: text(info?.infoLink),
81
+ previewLink: text(info?.previewLink),
82
+ imageUrl: text(cover?.thumbnail)?.replace(/^http:/, 'https:'),
83
+ coverSourceUrl: text(cover?.thumbnail)?.replace(/^http:/, 'https:'),
84
+ coverProvider: text(cover?.thumbnail) ? 'google-books' : undefined,
85
+ description: text(info?.description),
86
+ averageRating: typeof info?.averageRating === 'number' ? info.averageRating : undefined,
87
+ publisher: text(info?.publisher),
88
+ publishedDate: text(info?.publishedDate),
89
+ pageCount: typeof info?.pageCount === 'number' ? info.pageCount : undefined,
90
+ subjects: stringList(info?.categories),
91
+ language: text(info?.language),
92
+ };
93
+ }));
94
+ return books.filter((book) => Boolean(book));
95
+ }
package/dist/rss.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { GoodreadsBook } from './schema.js';
2
+ /** Parses the Goodreads shelf RSS XML into stable, source-only book fields. */
3
+ export declare function parseGoodreadsRss(xml: string): GoodreadsBook[];
package/dist/rss.js ADDED
@@ -0,0 +1,57 @@
1
+ import { XMLParser } from 'fast-xml-parser';
2
+ const parser = new XMLParser({
3
+ ignoreAttributes: false,
4
+ trimValues: true,
5
+ parseTagValue: false,
6
+ });
7
+ function text(value) {
8
+ if (value === undefined || value === null)
9
+ return undefined;
10
+ const result = String(value).trim();
11
+ return result || undefined;
12
+ }
13
+ function number(value) {
14
+ const result = Number(text(value));
15
+ return Number.isFinite(result) ? result : undefined;
16
+ }
17
+ function values(value) {
18
+ if (Array.isArray(value))
19
+ return value;
20
+ if (value && typeof value === 'object')
21
+ return [value];
22
+ return [];
23
+ }
24
+ function idFromLink(link) {
25
+ return link?.match(/(?:book\/show|review\/show)\/(\d+)/)?.[1];
26
+ }
27
+ /** Parses the Goodreads shelf RSS XML into stable, source-only book fields. */
28
+ export function parseGoodreadsRss(xml) {
29
+ const document = parser.parse(xml);
30
+ const items = values(document.rss?.channel?.item);
31
+ if (!document.rss?.channel) {
32
+ throw new Error('The response is not a valid RSS document (missing rss.channel).');
33
+ }
34
+ return items.flatMap((item) => {
35
+ const title = text(item.title);
36
+ if (!title)
37
+ return [];
38
+ const link = text(item.link);
39
+ return [{
40
+ source: 'goodreads-rss',
41
+ title,
42
+ goodreadsId: text(item.book_id) ?? idFromLink(text(item.guid)) ?? idFromLink(link),
43
+ isbn: text(item.isbn),
44
+ isbn13: text(item.isbn13),
45
+ author: text(item.author_name) ?? text(item['dc:creator']),
46
+ link,
47
+ imageUrl: text(item.book_image_url) ?? text(item.image_url),
48
+ coverSourceUrl: text(item.book_image_url) ?? text(item.image_url),
49
+ coverProvider: text(item.book_image_url) ?? text(item.image_url) ? 'goodreads-rss' : undefined,
50
+ description: text(item.book_description) ?? text(item.description),
51
+ averageRating: number(item.average_rating),
52
+ userRating: number(item.user_rating),
53
+ readAt: text(item.user_read_at),
54
+ addedAt: text(item.user_date_added) ?? text(item.pubDate),
55
+ }];
56
+ });
57
+ }
@@ -0,0 +1,257 @@
1
+ import { z } from 'zod';
2
+ export declare const bookOverrideSchema: z.ZodEffects<z.ZodEffects<z.ZodEffects<z.ZodObject<{
3
+ goodreadsId: z.ZodOptional<z.ZodString>;
4
+ isbn: z.ZodOptional<z.ZodString>;
5
+ isbn13: z.ZodOptional<z.ZodString>;
6
+ slug: z.ZodOptional<z.ZodString>;
7
+ note: z.ZodOptional<z.ZodString>;
8
+ review: z.ZodOptional<z.ZodString>;
9
+ recommended: z.ZodOptional<z.ZodBoolean>;
10
+ featured: z.ZodOptional<z.ZodBoolean>;
11
+ tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
12
+ }, "strip", z.ZodTypeAny, {
13
+ goodreadsId?: string | undefined;
14
+ isbn?: string | undefined;
15
+ isbn13?: string | undefined;
16
+ slug?: string | undefined;
17
+ note?: string | undefined;
18
+ review?: string | undefined;
19
+ recommended?: boolean | undefined;
20
+ featured?: boolean | undefined;
21
+ tags?: string[] | undefined;
22
+ }, {
23
+ goodreadsId?: string | undefined;
24
+ isbn?: string | undefined;
25
+ isbn13?: string | undefined;
26
+ slug?: string | undefined;
27
+ note?: string | undefined;
28
+ review?: string | undefined;
29
+ recommended?: boolean | undefined;
30
+ featured?: boolean | undefined;
31
+ tags?: string[] | undefined;
32
+ }>, {
33
+ goodreadsId?: string | undefined;
34
+ isbn?: string | undefined;
35
+ isbn13?: string | undefined;
36
+ slug?: string | undefined;
37
+ note?: string | undefined;
38
+ review?: string | undefined;
39
+ recommended?: boolean | undefined;
40
+ featured?: boolean | undefined;
41
+ tags?: string[] | undefined;
42
+ }, {
43
+ goodreadsId?: string | undefined;
44
+ isbn?: string | undefined;
45
+ isbn13?: string | undefined;
46
+ slug?: string | undefined;
47
+ note?: string | undefined;
48
+ review?: string | undefined;
49
+ recommended?: boolean | undefined;
50
+ featured?: boolean | undefined;
51
+ tags?: string[] | undefined;
52
+ }>, {
53
+ goodreadsId?: string | undefined;
54
+ isbn?: string | undefined;
55
+ isbn13?: string | undefined;
56
+ slug?: string | undefined;
57
+ note?: string | undefined;
58
+ review?: string | undefined;
59
+ recommended?: boolean | undefined;
60
+ featured?: boolean | undefined;
61
+ tags?: string[] | undefined;
62
+ }, {
63
+ goodreadsId?: string | undefined;
64
+ isbn?: string | undefined;
65
+ isbn13?: string | undefined;
66
+ slug?: string | undefined;
67
+ note?: string | undefined;
68
+ review?: string | undefined;
69
+ recommended?: boolean | undefined;
70
+ featured?: boolean | undefined;
71
+ tags?: string[] | undefined;
72
+ }>, {
73
+ goodreadsId?: string | undefined;
74
+ isbn?: string | undefined;
75
+ isbn13?: string | undefined;
76
+ slug?: string | undefined;
77
+ note?: string | undefined;
78
+ review?: string | undefined;
79
+ recommended?: boolean | undefined;
80
+ featured?: boolean | undefined;
81
+ tags?: string[] | undefined;
82
+ }, {
83
+ goodreadsId?: string | undefined;
84
+ isbn?: string | undefined;
85
+ isbn13?: string | undefined;
86
+ slug?: string | undefined;
87
+ note?: string | undefined;
88
+ review?: string | undefined;
89
+ recommended?: boolean | undefined;
90
+ featured?: boolean | undefined;
91
+ tags?: string[] | undefined;
92
+ }>;
93
+ export declare const bookOverridesSchema: z.ZodObject<{
94
+ books: z.ZodDefault<z.ZodArray<z.ZodEffects<z.ZodEffects<z.ZodEffects<z.ZodObject<{
95
+ goodreadsId: z.ZodOptional<z.ZodString>;
96
+ isbn: z.ZodOptional<z.ZodString>;
97
+ isbn13: z.ZodOptional<z.ZodString>;
98
+ slug: z.ZodOptional<z.ZodString>;
99
+ note: z.ZodOptional<z.ZodString>;
100
+ review: z.ZodOptional<z.ZodString>;
101
+ recommended: z.ZodOptional<z.ZodBoolean>;
102
+ featured: z.ZodOptional<z.ZodBoolean>;
103
+ tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
104
+ }, "strip", z.ZodTypeAny, {
105
+ goodreadsId?: string | undefined;
106
+ isbn?: string | undefined;
107
+ isbn13?: string | undefined;
108
+ slug?: string | undefined;
109
+ note?: string | undefined;
110
+ review?: string | undefined;
111
+ recommended?: boolean | undefined;
112
+ featured?: boolean | undefined;
113
+ tags?: string[] | undefined;
114
+ }, {
115
+ goodreadsId?: string | undefined;
116
+ isbn?: string | undefined;
117
+ isbn13?: string | undefined;
118
+ slug?: string | undefined;
119
+ note?: string | undefined;
120
+ review?: string | undefined;
121
+ recommended?: boolean | undefined;
122
+ featured?: boolean | undefined;
123
+ tags?: string[] | undefined;
124
+ }>, {
125
+ goodreadsId?: string | undefined;
126
+ isbn?: string | undefined;
127
+ isbn13?: string | undefined;
128
+ slug?: string | undefined;
129
+ note?: string | undefined;
130
+ review?: string | undefined;
131
+ recommended?: boolean | undefined;
132
+ featured?: boolean | undefined;
133
+ tags?: string[] | undefined;
134
+ }, {
135
+ goodreadsId?: string | undefined;
136
+ isbn?: string | undefined;
137
+ isbn13?: string | undefined;
138
+ slug?: string | undefined;
139
+ note?: string | undefined;
140
+ review?: string | undefined;
141
+ recommended?: boolean | undefined;
142
+ featured?: boolean | undefined;
143
+ tags?: string[] | undefined;
144
+ }>, {
145
+ goodreadsId?: string | undefined;
146
+ isbn?: string | undefined;
147
+ isbn13?: string | undefined;
148
+ slug?: string | undefined;
149
+ note?: string | undefined;
150
+ review?: string | undefined;
151
+ recommended?: boolean | undefined;
152
+ featured?: boolean | undefined;
153
+ tags?: string[] | undefined;
154
+ }, {
155
+ goodreadsId?: string | undefined;
156
+ isbn?: string | undefined;
157
+ isbn13?: string | undefined;
158
+ slug?: string | undefined;
159
+ note?: string | undefined;
160
+ review?: string | undefined;
161
+ recommended?: boolean | undefined;
162
+ featured?: boolean | undefined;
163
+ tags?: string[] | undefined;
164
+ }>, {
165
+ goodreadsId?: string | undefined;
166
+ isbn?: string | undefined;
167
+ isbn13?: string | undefined;
168
+ slug?: string | undefined;
169
+ note?: string | undefined;
170
+ review?: string | undefined;
171
+ recommended?: boolean | undefined;
172
+ featured?: boolean | undefined;
173
+ tags?: string[] | undefined;
174
+ }, {
175
+ goodreadsId?: string | undefined;
176
+ isbn?: string | undefined;
177
+ isbn13?: string | undefined;
178
+ slug?: string | undefined;
179
+ note?: string | undefined;
180
+ review?: string | undefined;
181
+ recommended?: boolean | undefined;
182
+ featured?: boolean | undefined;
183
+ tags?: string[] | undefined;
184
+ }>, "many">>;
185
+ }, "strip", z.ZodTypeAny, {
186
+ books: {
187
+ goodreadsId?: string | undefined;
188
+ isbn?: string | undefined;
189
+ isbn13?: string | undefined;
190
+ slug?: string | undefined;
191
+ note?: string | undefined;
192
+ review?: string | undefined;
193
+ recommended?: boolean | undefined;
194
+ featured?: boolean | undefined;
195
+ tags?: string[] | undefined;
196
+ }[];
197
+ }, {
198
+ books?: {
199
+ goodreadsId?: string | undefined;
200
+ isbn?: string | undefined;
201
+ isbn13?: string | undefined;
202
+ slug?: string | undefined;
203
+ note?: string | undefined;
204
+ review?: string | undefined;
205
+ recommended?: boolean | undefined;
206
+ featured?: boolean | undefined;
207
+ tags?: string[] | undefined;
208
+ }[] | undefined;
209
+ }>;
210
+ export type BookOverride = z.infer<typeof bookOverrideSchema>;
211
+ export type BookOverrides = z.infer<typeof bookOverridesSchema>;
212
+ export type BookSourceName = 'goodreads-rss' | 'open-library' | 'google-books';
213
+ export interface GoodreadsBook {
214
+ source?: BookSourceName;
215
+ sources?: BookSourceName[];
216
+ goodreadsId?: string;
217
+ isbn?: string;
218
+ isbn13?: string;
219
+ title: string;
220
+ author?: string;
221
+ link?: string;
222
+ imageUrl?: string;
223
+ description?: string;
224
+ averageRating?: number;
225
+ userRating?: number;
226
+ readAt?: string;
227
+ addedAt?: string;
228
+ publisher?: string;
229
+ publishedDate?: string;
230
+ pageCount?: number;
231
+ subjects?: string[];
232
+ language?: string;
233
+ previewLink?: string;
234
+ /** Original provider URL for a remote cover, retained when a local cover cache is used. */
235
+ coverSourceUrl?: string;
236
+ /** Provider that supplied the active cover; may differ from the book's primary metadata source. */
237
+ coverProvider?: BookSourceName;
238
+ coverAttribution?: {
239
+ provider: BookSourceName;
240
+ url: string;
241
+ };
242
+ }
243
+ export type Book = GoodreadsBook & Omit<BookOverride, 'goodreadsId' | 'isbn' | 'isbn13'> & {
244
+ /** First configured source that supplied this book. */
245
+ source: BookSourceName;
246
+ /** Every configured source successfully merged into this book. */
247
+ sources: BookSourceName[];
248
+ };
249
+ export interface BookCatalog {
250
+ books: Book[];
251
+ generatedAt: string;
252
+ source: {
253
+ rssUrl?: string;
254
+ usedCache: boolean;
255
+ providers: BookSourceName[];
256
+ };
257
+ }
package/dist/schema.js ADDED
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import { isValidIsbn, isValidIsbn13 } from './identifiers.js';
3
+ const identifier = z.string().trim().min(1);
4
+ export const bookOverrideSchema = z
5
+ .object({
6
+ goodreadsId: identifier.optional(),
7
+ isbn: identifier.optional(),
8
+ isbn13: identifier.optional(),
9
+ slug: z.string().trim().min(1).optional(),
10
+ note: z.string().optional(),
11
+ review: z.string().optional(),
12
+ recommended: z.boolean().optional(),
13
+ featured: z.boolean().optional(),
14
+ tags: z.array(z.string()).optional(),
15
+ })
16
+ .refine((book) => book.goodreadsId || book.isbn || book.isbn13, {
17
+ message: 'Each override needs goodreadsId, isbn, or isbn13.',
18
+ })
19
+ .refine((book) => !book.isbn || isValidIsbn(book.isbn), {
20
+ message: 'isbn must be a valid ISBN-10 or ISBN-13.',
21
+ path: ['isbn'],
22
+ })
23
+ .refine((book) => !book.isbn13 || isValidIsbn13(book.isbn13), {
24
+ message: 'isbn13 must be a valid ISBN-13.',
25
+ path: ['isbn13'],
26
+ });
27
+ export const bookOverridesSchema = z.object({
28
+ books: z.array(bookOverrideSchema).default([]),
29
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-book-bridge",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "packageManager": "pnpm@12.4.2",
5
5
  "description": "Build a typed, locally enriched Astro book catalog from Goodreads RSS, Open Library and Google Books.",
6
6
  "type": "module",