kmcom-nuxt-layers 2.5.1 → 2.6.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.
Files changed (58) hide show
  1. package/layers/core/app/utils/browserInfo.test.ts +81 -0
  2. package/layers/core/app/utils/featureClasses.test.ts +103 -0
  3. package/layers/core/app/utils/regex.test.ts +31 -0
  4. package/layers/feeds/nuxt.config.ts +7 -10
  5. package/layers/feeds/server/utils/feed-author.test.ts +71 -0
  6. package/layers/feeds/server/utils/feed-config.test.ts +162 -0
  7. package/layers/forms/app/utils/contact-schema.test.ts +75 -0
  8. package/layers/forms/app/utils/fieldProps.test.ts +141 -0
  9. package/layers/layout/app/utils/gridPlacementStyle.test.ts +310 -0
  10. package/layers/metadata/providers/comicvine/nuxt.config.ts +1 -1
  11. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/client.ts +13 -6
  12. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.test.ts +201 -0
  13. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.ts +34 -15
  14. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/provider.ts +26 -9
  15. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/types.ts +1 -0
  16. package/layers/metadata/providers/comicvine/tsconfig.json +1 -1
  17. package/layers/metadata/providers/google-books/nuxt.config.ts +1 -1
  18. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/client.test.ts +76 -0
  19. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/client.ts +20 -2
  20. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/normalise.test.ts +144 -0
  21. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/normalise.ts +21 -12
  22. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/provider.ts +11 -4
  23. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/types.ts +9 -1
  24. package/layers/metadata/providers/google-books/tsconfig.json +1 -1
  25. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.test.ts +25 -0
  26. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.ts +14 -6
  27. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.test.ts +142 -0
  28. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.ts +30 -24
  29. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/provider.ts +5 -1
  30. package/layers/metadata/providers/openlibrary/tsconfig.json +1 -1
  31. package/layers/metadata/providers/themoviedb/nuxt.config.ts +36 -0
  32. package/layers/metadata/providers/themoviedb/package.json +12 -0
  33. package/layers/metadata/providers/themoviedb/server/plugins/tmdb-register.ts +3 -0
  34. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.test.ts +31 -0
  35. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.ts +60 -0
  36. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.test.ts +162 -0
  37. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.ts +83 -0
  38. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/provider.ts +37 -0
  39. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/types.ts +81 -0
  40. package/layers/metadata/providers/themoviedb/tsconfig.json +5 -0
  41. package/layers/metadata/server/api/metadata/lookup.get.ts +7 -2
  42. package/layers/metadata/server/api/metadata/search.get.ts +7 -2
  43. package/layers/metadata/server/api/metadata/status.get.ts +24 -0
  44. package/layers/metadata/server/api/metadata/sync.post.ts +14 -4
  45. package/layers/metadata/server/utils/metadata/cache.test.ts +39 -0
  46. package/layers/metadata/server/utils/metadata/cache.ts +15 -3
  47. package/layers/metadata/server/utils/metadata/errors.ts +1 -1
  48. package/layers/metadata/server/utils/metadata/provider-registry.test.ts +54 -0
  49. package/layers/metadata/server/utils/metadata/search.test.ts +198 -0
  50. package/layers/metadata/server/utils/metadata/search.ts +9 -4
  51. package/layers/navigation/app/utils/site.test.ts +41 -0
  52. package/layers/seo/app/utils/seoConfig.test.ts +58 -0
  53. package/layers/theme/server/utils/accent-css.test.ts +56 -0
  54. package/layers/theme/server/utils/fouc-config.test.ts +74 -0
  55. package/layers/visual/app/utils/colorTokens.test.ts +51 -0
  56. package/layers/visual/app/utils/gradientStyle.test.ts +76 -0
  57. package/layers/visual/app/utils/responsiveSizes.test.ts +71 -0
  58. package/package.json +6 -2
@@ -1,26 +1,43 @@
1
1
  import type { MetadataProvider } from '#layers/metadata/shared/types'
2
2
 
3
+ // Matches CJK, Hangul, Arabic, Hebrew, Thai, Devanagari, Cyrillic, etc.
4
+ const NON_LATIN_RE = /[Ͱ-ϿЀ-ӿ؀-ۿऀ-ॿ -鿿가-힯豈-﫿]/
5
+
6
+ function isEnglish(title: string): boolean {
7
+ return !NON_LATIN_RE.test(title)
8
+ }
9
+
3
10
  export const comicVineProvider: MetadataProvider = {
4
11
  id: 'comicvine',
5
12
  label: 'Comic Vine',
6
- mediaTypes: ['comic', 'graphic-novel', 'collected-edition'],
13
+ mediaTypes: ['comic', 'comic-series'],
7
14
 
8
- // fallow-ignore-next-line complexity
9
15
  async search({ query, mediaType, limit = 10 }) {
10
- const resourceType = mediaType === 'collected-edition' || mediaType === 'graphic-novel' ? 'volume' : 'issue'
11
-
12
- if (resourceType === 'volume') {
16
+ if (mediaType === 'comic-series') {
13
17
  const res = await searchComicVineVolumes(query, limit)
14
- return (Array.isArray(res.results) ? res.results : []).map(normaliseComicVineVolume)
18
+ return (Array.isArray(res.results) ? res.results : [])
19
+ .filter((v) => isEnglish(v.name))
20
+ .map(normaliseComicVineVolume)
21
+ }
22
+
23
+ if (mediaType === 'comic') {
24
+ const res = await searchComicVineIssues(query, limit)
25
+ return (Array.isArray(res.results) ? res.results : [])
26
+ .filter((i) => isEnglish(i.name ?? i.volume?.name ?? ''))
27
+ .map(normaliseComicVineIssue)
15
28
  }
16
29
 
30
+ // All — split limit between issues and series
17
31
  const [issueRes, volumeRes] = await Promise.all([
18
32
  searchComicVineIssues(query, Math.ceil(limit / 2)),
19
33
  searchComicVineVolumes(query, Math.floor(limit / 2)),
20
34
  ])
21
-
22
- const issues = (Array.isArray(issueRes.results) ? issueRes.results : []).map(normaliseComicVineIssue)
23
- const volumes = (Array.isArray(volumeRes.results) ? volumeRes.results : []).map(normaliseComicVineVolume)
35
+ const issues = (Array.isArray(issueRes.results) ? issueRes.results : [])
36
+ .filter((i) => isEnglish(i.name ?? i.volume?.name ?? ''))
37
+ .map(normaliseComicVineIssue)
38
+ const volumes = (Array.isArray(volumeRes.results) ? volumeRes.results : [])
39
+ .filter((v) => isEnglish(v.name))
40
+ .map(normaliseComicVineVolume)
24
41
  return [...issues, ...volumes]
25
42
  },
26
43
 
@@ -29,6 +29,7 @@ export type ComicVineVolume = {
29
29
  description: string | null
30
30
  deck: string | null
31
31
  start_year: string | null
32
+ count_of_issues: number | null
32
33
  site_detail_url: string
33
34
  image: { original_url: string; medium_url: string } | null
34
35
  publisher: { id: number; name: string } | null
@@ -1,5 +1,5 @@
1
1
  {
2
- "extends": "../../../apps/playground/tsconfig.json",
2
+ "extends": "../../../../apps/playground/tsconfig.json",
3
3
  "compilerOptions": {
4
4
  "strict": true
5
5
  },
@@ -24,7 +24,7 @@ export default defineNuxtConfig({
24
24
  })
25
25
 
26
26
  declare module '@nuxt/schema' {
27
- interface RuntimeConfig {
27
+ type RuntimeConfig = {
28
28
  metadataGoogleBooks: {
29
29
  apiKey: string
30
30
  }
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { googleBooksCoverUrl, googleBooksMediaType } from './client'
4
+
5
+ describe('googleBooksMediaType', () => {
6
+ it('classifies manga from categories', () => {
7
+ expect(googleBooksMediaType(['Comics & Graphic Novels / Manga'])).toBe('manga')
8
+ })
9
+
10
+ it('classifies comic as graphic-novel', () => {
11
+ expect(googleBooksMediaType(['Comics & Graphic Novels / Comic'])).toBe('graphic-novel')
12
+ })
13
+
14
+ it('classifies "graphic novel" as graphic-novel', () => {
15
+ expect(googleBooksMediaType(['Fiction / Graphic Novel'])).toBe('graphic-novel')
16
+ })
17
+
18
+ it('is case-insensitive', () => {
19
+ expect(googleBooksMediaType(['MANGA'])).toBe('manga')
20
+ expect(googleBooksMediaType(['COMIC'])).toBe('graphic-novel')
21
+ })
22
+
23
+ it('defaults to book for unrelated categories', () => {
24
+ expect(googleBooksMediaType(['Fiction / Thriller'])).toBe('book')
25
+ })
26
+
27
+ it('defaults to book when categories is undefined', () => {
28
+ expect(googleBooksMediaType(undefined)).toBe('book')
29
+ })
30
+
31
+ it('defaults to book when categories is empty', () => {
32
+ expect(googleBooksMediaType([])).toBe('book')
33
+ })
34
+
35
+ it('prefers manga over comic when both are present', () => {
36
+ expect(googleBooksMediaType(['Manga / Comic'])).toBe('manga')
37
+ })
38
+ })
39
+
40
+ describe('googleBooksCoverUrl', () => {
41
+ it('upgrades http to https', () => {
42
+ const url = googleBooksCoverUrl('http://books.google.com/books/content?id=abc&zoom=1')
43
+ expect(url.startsWith('https://')).toBe(true)
44
+ })
45
+
46
+ it('rewrites zoom=N to zoom=3', () => {
47
+ const url = googleBooksCoverUrl('https://books.google.com/books/content?id=abc&zoom=1')
48
+ expect(url).toContain('&zoom=3')
49
+ expect(url).not.toContain('&zoom=1')
50
+ })
51
+
52
+ it('strips &edge=curl', () => {
53
+ const url = googleBooksCoverUrl(
54
+ 'https://books.google.com/books/content?id=abc&zoom=1&edge=curl'
55
+ )
56
+ expect(url).not.toContain('edge=curl')
57
+ })
58
+
59
+ it('appends &fife=w{width} using the default width of 800', () => {
60
+ const url = googleBooksCoverUrl('https://books.google.com/books/content?id=abc&zoom=1')
61
+ expect(url.endsWith('&fife=w800')).toBe(true)
62
+ })
63
+
64
+ it('appends &fife=w{width} using a custom width', () => {
65
+ const url = googleBooksCoverUrl('https://books.google.com/books/content?id=abc&zoom=1', 200)
66
+ expect(url.endsWith('&fife=w200')).toBe(true)
67
+ })
68
+
69
+ it('applies all transformations together', () => {
70
+ const url = googleBooksCoverUrl(
71
+ 'http://books.google.com/books/content?id=abc&zoom=5&edge=curl',
72
+ 400
73
+ )
74
+ expect(url).toBe('https://books.google.com/books/content?id=abc&zoom=3&fife=w400')
75
+ })
76
+ })
@@ -2,12 +2,30 @@ import type { GoogleBooksSearchResponse, GoogleBooksVolume } from './types'
2
2
 
3
3
  const BASE = 'https://www.googleapis.com/books/v1'
4
4
 
5
+ export function googleBooksMediaType(categories?: string[]): import('#layers/metadata/shared/types').MetadataMediaType {
6
+ const joined = (categories ?? []).join(' ').toLowerCase()
7
+ if (joined.includes('manga')) return 'manga'
8
+ if (joined.includes('comic') || joined.includes('graphic novel')) return 'graphic-novel'
9
+ return 'book'
10
+ }
11
+
12
+ export function googleBooksCoverUrl(url: string, width = 800): string {
13
+ return url
14
+ .replace('http://', 'https://')
15
+ .replace(/&zoom=\d+/, '&zoom=3')
16
+ .replace('&edge=curl', '')
17
+ + `&fife=w${width}`
18
+ }
19
+
5
20
  function getApiKey(): string | undefined {
6
21
  return useRuntimeConfig().metadataGoogleBooks?.apiKey || undefined
7
22
  }
8
23
 
9
- export async function searchGoogleBooks(query: string, limit = 10): Promise<GoogleBooksSearchResponse> {
10
- const params: Record<string, string> = { q: query, maxResults: String(limit) }
24
+ export async function searchGoogleBooks(
25
+ query: string,
26
+ limit = 10
27
+ ): Promise<GoogleBooksSearchResponse> {
28
+ const params: Record<string, string> = { q: query, maxResults: String(limit), langRestrict: 'en' }
11
29
  const apiKey = getApiKey()
12
30
  if (apiKey) params.key = apiKey
13
31
 
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { googleBooksCoverUrl, googleBooksMediaType } from './client'
4
+ import { normaliseGoogleBooksVolume } from './normalise'
5
+ import type { GoogleBooksVolume } from './types'
6
+
7
+ // normalise.ts calls googleBooksMediaType()/googleBooksCoverUrl() as Nitro auto-imports;
8
+ // stub them with the real (pure, string-building) implementations from client.ts.
9
+ vi.stubGlobal('googleBooksMediaType', googleBooksMediaType)
10
+ vi.stubGlobal('googleBooksCoverUrl', googleBooksCoverUrl)
11
+
12
+ function baseVolume(overrides: Partial<GoogleBooksVolume['volumeInfo']> = {}): GoogleBooksVolume {
13
+ return {
14
+ id: 'abc123',
15
+ selfLink: 'https://www.googleapis.com/books/v1/volumes/abc123',
16
+ volumeInfo: {
17
+ title: 'Watchmen',
18
+ ...overrides,
19
+ },
20
+ }
21
+ }
22
+
23
+ describe('normaliseGoogleBooksVolume', () => {
24
+ it('prefers extraLarge over all other image sizes', () => {
25
+ const record = normaliseGoogleBooksVolume(
26
+ baseVolume({
27
+ imageLinks: {
28
+ smallThumbnail: 's.jpg',
29
+ thumbnail: 't.jpg',
30
+ small: 'sm.jpg',
31
+ medium: 'm.jpg',
32
+ large: 'l.jpg',
33
+ extraLarge: 'xl.jpg',
34
+ },
35
+ })
36
+ )
37
+ expect(record.coverUrl).toBe(googleBooksCoverUrl('xl.jpg'))
38
+ })
39
+
40
+ it('falls back to large when extraLarge is absent', () => {
41
+ const record = normaliseGoogleBooksVolume(
42
+ baseVolume({ imageLinks: { large: 'l.jpg', medium: 'm.jpg' } })
43
+ )
44
+ expect(record.coverUrl).toBe(googleBooksCoverUrl('l.jpg'))
45
+ })
46
+
47
+ it('falls back to medium when extraLarge/large are absent', () => {
48
+ const record = normaliseGoogleBooksVolume(
49
+ baseVolume({ imageLinks: { medium: 'm.jpg', small: 'sm.jpg' } })
50
+ )
51
+ expect(record.coverUrl).toBe(googleBooksCoverUrl('m.jpg'))
52
+ })
53
+
54
+ it('falls back to small when extraLarge/large/medium are absent', () => {
55
+ const record = normaliseGoogleBooksVolume(
56
+ baseVolume({ imageLinks: { small: 'sm.jpg', thumbnail: 't.jpg' } })
57
+ )
58
+ expect(record.coverUrl).toBe(googleBooksCoverUrl('sm.jpg'))
59
+ })
60
+
61
+ it('falls back to thumbnail when larger sizes are absent', () => {
62
+ const record = normaliseGoogleBooksVolume(
63
+ baseVolume({ imageLinks: { thumbnail: 't.jpg', smallThumbnail: 's.jpg' } })
64
+ )
65
+ expect(record.coverUrl).toBe(googleBooksCoverUrl('t.jpg'))
66
+ })
67
+
68
+ it('falls back to smallThumbnail as the last resort', () => {
69
+ const record = normaliseGoogleBooksVolume(
70
+ baseVolume({ imageLinks: { smallThumbnail: 's.jpg' } })
71
+ )
72
+ expect(record.coverUrl).toBe(googleBooksCoverUrl('s.jpg'))
73
+ })
74
+
75
+ it('leaves coverUrl undefined when imageLinks is absent', () => {
76
+ expect(normaliseGoogleBooksVolume(baseVolume()).coverUrl).toBeUndefined()
77
+ })
78
+
79
+ it('extracts isbn10/isbn13 from industryIdentifiers', () => {
80
+ const record = normaliseGoogleBooksVolume(
81
+ baseVolume({
82
+ industryIdentifiers: [
83
+ { type: 'ISBN_10', identifier: '0930289232' },
84
+ { type: 'ISBN_13', identifier: '9780930289232' },
85
+ ],
86
+ })
87
+ )
88
+ expect(record.identifiers).toMatchObject({
89
+ isbn10: '0930289232',
90
+ isbn13: '9780930289232',
91
+ })
92
+ })
93
+
94
+ it('leaves isbn10/isbn13 undefined when industryIdentifiers is absent', () => {
95
+ const record = normaliseGoogleBooksVolume(baseVolume())
96
+ expect(record.identifiers?.isbn10).toBeUndefined()
97
+ expect(record.identifiers?.isbn13).toBeUndefined()
98
+ })
99
+
100
+ it('always sets identifiers.googleBooksId to the volume id', () => {
101
+ const record = normaliseGoogleBooksVolume(baseVolume())
102
+ expect(record.identifiers?.googleBooksId).toBe('abc123')
103
+ })
104
+
105
+ it('classifies mediaType via googleBooksMediaType(categories)', () => {
106
+ const manga = normaliseGoogleBooksVolume(baseVolume({ categories: ['Manga'] }))
107
+ expect(manga.mediaType).toBe('manga')
108
+
109
+ const book = normaliseGoogleBooksVolume(baseVolume({ categories: ['Fiction'] }))
110
+ expect(book.mediaType).toBe('book')
111
+ })
112
+
113
+ it('maps authors to creators with the author role', () => {
114
+ const record = normaliseGoogleBooksVolume(baseVolume({ authors: ['Alan Moore'] }))
115
+ expect(record.creators).toEqual([{ name: 'Alan Moore', role: 'author' }])
116
+ })
117
+
118
+ it('leaves creators undefined when authors is absent', () => {
119
+ expect(normaliseGoogleBooksVolume(baseVolume()).creators).toBeUndefined()
120
+ })
121
+
122
+ it('prefers canonicalVolumeLink over infoLink for sourceUrl', () => {
123
+ const record = normaliseGoogleBooksVolume(
124
+ baseVolume({ canonicalVolumeLink: 'canonical.url', infoLink: 'info.url' })
125
+ )
126
+ expect(record.sourceUrl).toBe('canonical.url')
127
+ })
128
+
129
+ it('falls back to infoLink when canonicalVolumeLink is absent', () => {
130
+ const record = normaliseGoogleBooksVolume(baseVolume({ infoLink: 'info.url' }))
131
+ expect(record.sourceUrl).toBe('info.url')
132
+ })
133
+
134
+ it('stamps lastSyncedAt as a string', () => {
135
+ expect(normaliseGoogleBooksVolume(baseVolume()).lastSyncedAt).toEqual(expect.any(String))
136
+ })
137
+
138
+ it('sets id and provider fields from the volume id', () => {
139
+ const record = normaliseGoogleBooksVolume(baseVolume())
140
+ expect(record.id).toBe('google-books:volume:abc123')
141
+ expect(record.provider).toBe('google-books')
142
+ expect(record.providerId).toBe('abc123')
143
+ })
144
+ })
@@ -1,4 +1,5 @@
1
- import type { MetadataRecord, MetadataCreator } from '#layers/metadata/shared/types'
1
+ import type { MetadataCreator, MetadataRecord } from '#layers/metadata/shared/types'
2
+
2
3
  import type { GoogleBooksVolume } from './types'
3
4
 
4
5
  // fallow-ignore-next-line complexity
@@ -13,26 +14,34 @@ export function normaliseGoogleBooksVolume(volume: GoogleBooksVolume): MetadataR
13
14
  const isbn10 = volumeInfo.industryIdentifiers?.find((i) => i.type === 'ISBN_10')?.identifier
14
15
  const isbn13 = volumeInfo.industryIdentifiers?.find((i) => i.type === 'ISBN_13')?.identifier
15
16
 
16
- const cover = volumeInfo.imageLinks?.thumbnail ?? volumeInfo.imageLinks?.smallThumbnail
17
+ const rawCover =
18
+ volumeInfo.imageLinks?.extraLarge ??
19
+ volumeInfo.imageLinks?.large ??
20
+ volumeInfo.imageLinks?.medium ??
21
+ volumeInfo.imageLinks?.small ??
22
+ volumeInfo.imageLinks?.thumbnail ??
23
+ volumeInfo.imageLinks?.smallThumbnail
24
+
25
+ const sourceUrl = volumeInfo.canonicalVolumeLink ?? volumeInfo.infoLink
17
26
 
18
27
  return {
19
28
  id: `google-books:volume:${volume.id}`,
20
29
  provider: 'google-books',
21
30
  providerId: volume.id,
22
- mediaType: 'book',
31
+ mediaType: googleBooksMediaType(volumeInfo.categories),
23
32
  title: volumeInfo.title,
24
- subtitle: volumeInfo.subtitle,
25
- description: volumeInfo.description,
26
- creators: creators.length ? creators : undefined,
27
- publisher: volumeInfo.publisher,
28
- publishedAt: volumeInfo.publishedDate,
29
- coverUrl: cover ? cover.replace('http://', 'https://') : undefined,
33
+ ...(volumeInfo.subtitle && { subtitle: volumeInfo.subtitle }),
34
+ ...(volumeInfo.description && { description: volumeInfo.description }),
35
+ ...(creators.length && { creators }),
36
+ ...(volumeInfo.publisher && { publisher: volumeInfo.publisher }),
37
+ ...(volumeInfo.publishedDate && { publishedAt: volumeInfo.publishedDate }),
38
+ ...(rawCover && { coverUrl: googleBooksCoverUrl(rawCover) }),
30
39
  identifiers: {
31
- isbn10,
32
- isbn13,
40
+ ...(isbn10 && { isbn10 }),
41
+ ...(isbn13 && { isbn13 }),
33
42
  googleBooksId: volume.id,
34
43
  },
35
- sourceUrl: volumeInfo.canonicalVolumeLink ?? volumeInfo.infoLink,
44
+ ...(sourceUrl && { sourceUrl }),
36
45
  raw: volume,
37
46
  lastSyncedAt: new Date().toISOString(),
38
47
  }
@@ -3,11 +3,18 @@ import type { MetadataProvider } from '#layers/metadata/shared/types'
3
3
  export const googleBooksProvider: MetadataProvider = {
4
4
  id: 'google-books',
5
5
  label: 'Google Books',
6
- mediaTypes: ['book', 'graphic-novel', 'collected-edition'],
6
+ mediaTypes: ['book', 'graphic-novel', 'manga'],
7
7
 
8
- async search({ query, limit = 10 }) {
9
- const res = await searchGoogleBooks(query, limit)
10
- return (res.items ?? []).map(normaliseGoogleBooksVolume)
8
+ async search({ query, mediaType, limit = 10 }) {
9
+ const subjectHints: Record<string, string> = {
10
+ 'graphic-novel': '+subject:"Comics & Graphic Novels"',
11
+ 'manga': '+subject:manga',
12
+ 'book': '+-subject:"Comics & Graphic Novels"+-subject:manga',
13
+ }
14
+ const q = query + (mediaType ? (subjectHints[mediaType] ?? '') : '')
15
+ const res = await searchGoogleBooks(q, limit)
16
+ const records = (res.items ?? []).map(normaliseGoogleBooksVolume)
17
+ return mediaType ? records.filter((r) => r.mediaType === mediaType) : records
11
18
  },
12
19
 
13
20
  async lookup({ providerId }) {
@@ -7,7 +7,15 @@ export type GoogleBooksVolumeInfo = {
7
7
  description?: string
8
8
  industryIdentifiers?: Array<{ type: 'ISBN_10' | 'ISBN_13' | string; identifier: string }>
9
9
  pageCount?: number
10
- imageLinks?: { thumbnail?: string; smallThumbnail?: string }
10
+ imageLinks?: {
11
+ smallThumbnail?: string
12
+ thumbnail?: string
13
+ small?: string
14
+ medium?: string
15
+ large?: string
16
+ extraLarge?: string
17
+ }
18
+ categories?: string[]
11
19
  previewLink?: string
12
20
  infoLink?: string
13
21
  canonicalVolumeLink?: string
@@ -1,5 +1,5 @@
1
1
  {
2
- "extends": "../../../apps/playground/tsconfig.json",
2
+ "extends": "../../../../apps/playground/tsconfig.json",
3
3
  "compilerOptions": {
4
4
  "strict": true
5
5
  },
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { openLibraryCoverUrl } from './client'
4
+
5
+ describe('openLibraryCoverUrl', () => {
6
+ it('builds the covers.openlibrary.org URL shape', () => {
7
+ expect(openLibraryCoverUrl(12345)).toBe('https://covers.openlibrary.org/b/id/12345-L.jpg')
8
+ })
9
+
10
+ it('defaults to size L when no size is given', () => {
11
+ expect(openLibraryCoverUrl(1)).toBe('https://covers.openlibrary.org/b/id/1-L.jpg')
12
+ })
13
+
14
+ it('supports the S size', () => {
15
+ expect(openLibraryCoverUrl(1, 'S')).toBe('https://covers.openlibrary.org/b/id/1-S.jpg')
16
+ })
17
+
18
+ it('supports the M size', () => {
19
+ expect(openLibraryCoverUrl(1, 'M')).toBe('https://covers.openlibrary.org/b/id/1-M.jpg')
20
+ })
21
+
22
+ it('supports the L size explicitly', () => {
23
+ expect(openLibraryCoverUrl(1, 'L')).toBe('https://covers.openlibrary.org/b/id/1-L.jpg')
24
+ })
25
+ })
@@ -1,14 +1,23 @@
1
- import type { OpenLibrarySearchResponse, OpenLibraryWork, OpenLibraryEdition } from './types'
1
+ import type { OpenLibraryEdition, OpenLibrarySearchResponse, OpenLibraryWork } from './types'
2
2
 
3
3
  const BASE = 'https://openlibrary.org'
4
4
 
5
- export function openLibraryCoverUrl(coverId: number, size: 'S' | 'M' | 'L' = 'M'): string {
5
+ export function openLibraryCoverUrl(coverId: number, size: 'S' | 'M' | 'L' = 'L'): string {
6
6
  return `https://covers.openlibrary.org/b/id/${coverId}-${size}.jpg`
7
7
  }
8
8
 
9
- export async function searchOpenLibrary(query: string, limit = 10): Promise<OpenLibrarySearchResponse> {
9
+ export async function searchOpenLibrary(
10
+ query: string,
11
+ limit = 10
12
+ ): Promise<OpenLibrarySearchResponse> {
10
13
  return $fetch<OpenLibrarySearchResponse>(`${BASE}/search.json`, {
11
- query: { q: query, limit, fields: 'key,title,subtitle,author_name,author_key,publisher,first_publish_year,isbn,cover_i,number_of_pages_median' },
14
+ timeout: 10000,
15
+ query: {
16
+ q: query,
17
+ limit,
18
+ fields:
19
+ 'key,title,subtitle,author_name,author_key,publisher,first_publish_year,isbn,cover_i,number_of_pages_median',
20
+ },
12
21
  })
13
22
  }
14
23
 
@@ -23,8 +32,7 @@ export async function lookupOpenLibraryEdition(editionId: string): Promise<OpenL
23
32
  export async function lookupByIsbn(isbn: string): Promise<OpenLibraryEdition | null> {
24
33
  try {
25
34
  return await $fetch<OpenLibraryEdition>(`${BASE}/isbn/${isbn}.json`)
26
- }
27
- catch {
35
+ } catch {
28
36
  return null
29
37
  }
30
38
  }
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { openLibraryCoverUrl } from './client'
4
+ import {
5
+ normaliseOpenLibraryEdition,
6
+ normaliseOpenLibrarySearchDoc,
7
+ normaliseOpenLibraryWork,
8
+ } from './normalise'
9
+ import type { OpenLibraryEdition, OpenLibrarySearchDoc, OpenLibraryWork } from './types'
10
+
11
+ // normalise.ts calls openLibraryCoverUrl() as a Nitro auto-import; stub it with the real
12
+ // implementation from client.ts since that function is pure (no $fetch/config involved).
13
+ vi.stubGlobal('openLibraryCoverUrl', openLibraryCoverUrl)
14
+
15
+ function baseSearchDoc(overrides: Partial<OpenLibrarySearchDoc> = {}): OpenLibrarySearchDoc {
16
+ return {
17
+ key: '/works/OL82563W',
18
+ title: 'Watchmen',
19
+ ...overrides,
20
+ }
21
+ }
22
+
23
+ function baseWork(overrides: Partial<OpenLibraryWork> = {}): OpenLibraryWork {
24
+ return {
25
+ key: '/works/OL82563W',
26
+ title: 'Watchmen',
27
+ ...overrides,
28
+ }
29
+ }
30
+
31
+ function baseEdition(overrides: Partial<OpenLibraryEdition> = {}): OpenLibraryEdition {
32
+ return {
33
+ key: '/books/OL456M',
34
+ title: 'Watchmen',
35
+ ...overrides,
36
+ }
37
+ }
38
+
39
+ describe('normaliseOpenLibrarySearchDoc', () => {
40
+ it('strips /works/ from the key to derive the id', () => {
41
+ const record = normaliseOpenLibrarySearchDoc(baseSearchDoc({ key: '/works/OL82563W' }))
42
+ expect(record.providerId).toBe('OL82563W')
43
+ expect(record.id).toBe('openlibrary:work:OL82563W')
44
+ })
45
+
46
+ it('splits isbn[] into isbn10/isbn13 by length', () => {
47
+ const record = normaliseOpenLibrarySearchDoc(
48
+ baseSearchDoc({ isbn: ['0930289232', '9780930289232'] })
49
+ )
50
+ expect(record.identifiers).toMatchObject({ isbn10: '0930289232', isbn13: '9780930289232' })
51
+ })
52
+
53
+ it('leaves isbn10/isbn13 undefined when isbn is absent', () => {
54
+ const record = normaliseOpenLibrarySearchDoc(baseSearchDoc())
55
+ expect(record.identifiers?.isbn10).toBeUndefined()
56
+ expect(record.identifiers?.isbn13).toBeUndefined()
57
+ })
58
+
59
+ it('maps author_name/author_key pairs into creators, stripping /authors/', () => {
60
+ const record = normaliseOpenLibrarySearchDoc(
61
+ baseSearchDoc({ author_name: ['Alan Moore'], author_key: ['/authors/OL123A'] })
62
+ )
63
+ expect(record.creators).toEqual([{ name: 'Alan Moore', role: 'author', providerId: 'OL123A' }])
64
+ })
65
+
66
+ it('leaves creators undefined when there are no authors', () => {
67
+ expect(normaliseOpenLibrarySearchDoc(baseSearchDoc()).creators).toBeUndefined()
68
+ })
69
+
70
+ it('builds coverUrl from cover_i using openLibraryCoverUrl', () => {
71
+ const record = normaliseOpenLibrarySearchDoc(baseSearchDoc({ cover_i: 12345 }))
72
+ expect(record.coverUrl).toBe(openLibraryCoverUrl(12345))
73
+ })
74
+
75
+ it('leaves coverUrl undefined when cover_i is absent', () => {
76
+ expect(normaliseOpenLibrarySearchDoc(baseSearchDoc()).coverUrl).toBeUndefined()
77
+ })
78
+
79
+ it('stamps lastSyncedAt as a string', () => {
80
+ expect(normaliseOpenLibrarySearchDoc(baseSearchDoc()).lastSyncedAt).toEqual(expect.any(String))
81
+ })
82
+ })
83
+
84
+ describe('normaliseOpenLibraryWork', () => {
85
+ it('strips /works/ from the key to derive the id', () => {
86
+ const record = normaliseOpenLibraryWork(baseWork({ key: '/works/OL82563W' }))
87
+ expect(record.providerId).toBe('OL82563W')
88
+ expect(record.id).toBe('openlibrary:work:OL82563W')
89
+ })
90
+
91
+ it('uses the description string directly when it is a string', () => {
92
+ const record = normaliseOpenLibraryWork(baseWork({ description: 'Plain text description' }))
93
+ expect(record.description).toBe('Plain text description')
94
+ })
95
+
96
+ it('extracts description.value when description is an object', () => {
97
+ const record = normaliseOpenLibraryWork(
98
+ baseWork({ description: { value: 'Wrapped description' } })
99
+ )
100
+ expect(record.description).toBe('Wrapped description')
101
+ })
102
+
103
+ it('leaves description undefined when absent', () => {
104
+ expect(normaliseOpenLibraryWork(baseWork()).description).toBeUndefined()
105
+ })
106
+
107
+ it('builds coverUrl from covers[0]', () => {
108
+ const record = normaliseOpenLibraryWork(baseWork({ covers: [777, 888] }))
109
+ expect(record.coverUrl).toBe(openLibraryCoverUrl(777))
110
+ })
111
+ })
112
+
113
+ describe('normaliseOpenLibraryEdition', () => {
114
+ it('strips /books/ from the key to derive the id', () => {
115
+ const record = normaliseOpenLibraryEdition(baseEdition({ key: '/books/OL456M' }))
116
+ expect(record.providerId).toBe('OL456M')
117
+ expect(record.id).toBe('openlibrary:edition:OL456M')
118
+ })
119
+
120
+ it('reads isbn10/isbn13 from the first entries of isbn_10/isbn_13', () => {
121
+ const record = normaliseOpenLibraryEdition(
122
+ baseEdition({ isbn_10: ['0930289232'], isbn_13: ['9780930289232', '9999999999999'] })
123
+ )
124
+ expect(record.identifiers).toMatchObject({ isbn10: '0930289232', isbn13: '9780930289232' })
125
+ })
126
+
127
+ it('leaves isbn10/isbn13 undefined when absent', () => {
128
+ const record = normaliseOpenLibraryEdition(baseEdition())
129
+ expect(record.identifiers?.isbn10).toBeUndefined()
130
+ expect(record.identifiers?.isbn13).toBeUndefined()
131
+ })
132
+
133
+ it('builds coverUrl from covers[0]', () => {
134
+ const record = normaliseOpenLibraryEdition(baseEdition({ covers: [111] }))
135
+ expect(record.coverUrl).toBe(openLibraryCoverUrl(111))
136
+ })
137
+
138
+ it('takes publisher from publishers[0]', () => {
139
+ const record = normaliseOpenLibraryEdition(baseEdition({ publishers: ['DC Comics', 'Vertigo'] }))
140
+ expect(record.publisher).toBe('DC Comics')
141
+ })
142
+ })