kmcom-nuxt-layers 2.3.1 → 2.5.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 (76) hide show
  1. package/README.md +2 -0
  2. package/docs/ARCHITECTURE.md +4 -0
  3. package/layers/auth/app/middleware/auth.ts +4 -0
  4. package/layers/auth/nuxt.config.ts +31 -0
  5. package/layers/auth/package.json +15 -0
  6. package/layers/auth/server/routes/auth/github.get.ts +13 -0
  7. package/layers/auth/tsconfig.json +7 -0
  8. package/layers/baseline/app/components/BaselineStatus.vue +17 -0
  9. package/layers/baseline/app/plugins/baseline-status.client.ts +3 -0
  10. package/layers/baseline/nuxt.config.ts +24 -0
  11. package/layers/baseline/package.json +14 -0
  12. package/layers/baseline/tsconfig.json +7 -0
  13. package/layers/content/app/composables/createPortfolioComposables.ts +2 -1
  14. package/layers/content/app/composables/useBlogPosts.ts +2 -1
  15. package/layers/content/app/composables/useGalleryItems.ts +14 -8
  16. package/layers/content/app/composables/usePortfolioItems.ts +18 -10
  17. package/layers/database/nuxt.config.ts +30 -0
  18. package/layers/database/package.json +16 -0
  19. package/layers/database/server/utils/drizzle.ts +5 -0
  20. package/layers/database/server/utils/sql.ts +7 -0
  21. package/layers/database/tsconfig.json +7 -0
  22. package/layers/feeds/server/utils/content-adapter.ts +6 -0
  23. package/layers/forms/app/components/Form/Contact.vue +4 -13
  24. package/layers/forms/app/utils/contact-schema.ts +9 -0
  25. package/layers/forms/server/api/contact.post.ts +23 -6
  26. package/layers/forms/server/api/forms/status.get.ts +1 -3
  27. package/layers/layout/app/utils/gridPlacementStyle.ts +10 -9
  28. package/layers/metadata/app/composables/useMetadataItem.ts +11 -0
  29. package/layers/metadata/app/composables/useMetadataSearch.ts +25 -0
  30. package/layers/metadata/nuxt.config.ts +27 -0
  31. package/layers/metadata/package.json +12 -0
  32. package/layers/metadata/server/api/metadata/lookup.get.ts +40 -0
  33. package/layers/metadata/server/api/metadata/providers.get.ts +7 -0
  34. package/layers/metadata/server/api/metadata/search.get.ts +17 -0
  35. package/layers/metadata/server/api/metadata/sync.post.ts +40 -0
  36. package/layers/metadata/server/utils/metadata/cache.ts +36 -0
  37. package/layers/metadata/server/utils/metadata/errors.ts +24 -0
  38. package/layers/metadata/server/utils/metadata/provider-registry.ts +15 -0
  39. package/layers/metadata/server/utils/metadata/search.ts +42 -0
  40. package/layers/metadata/tsconfig.json +7 -0
  41. package/layers/metadata-comicvine/nuxt.config.ts +36 -0
  42. package/layers/metadata-comicvine/package.json +12 -0
  43. package/layers/metadata-comicvine/server/plugins/comicvine-register.ts +3 -0
  44. package/layers/metadata-comicvine/server/utils/metadata-comicvine/client.ts +52 -0
  45. package/layers/metadata-comicvine/server/utils/metadata-comicvine/normalise.ts +55 -0
  46. package/layers/metadata-comicvine/server/utils/metadata-comicvine/provider.ts +39 -0
  47. package/layers/metadata-comicvine/server/utils/metadata-comicvine/types.ts +36 -0
  48. package/layers/metadata-comicvine/tsconfig.json +7 -0
  49. package/layers/metadata-google-books/nuxt.config.ts +32 -0
  50. package/layers/metadata-google-books/package.json +12 -0
  51. package/layers/metadata-google-books/server/plugins/google-books-register.ts +3 -0
  52. package/layers/metadata-google-books/server/utils/metadata-google-books/client.ts +23 -0
  53. package/layers/metadata-google-books/server/utils/metadata-google-books/normalise.ts +39 -0
  54. package/layers/metadata-google-books/server/utils/metadata-google-books/provider.ts +21 -0
  55. package/layers/metadata-google-books/server/utils/metadata-google-books/types.ts +25 -0
  56. package/layers/metadata-google-books/tsconfig.json +7 -0
  57. package/layers/metadata-openlibrary/nuxt.config.ts +18 -0
  58. package/layers/metadata-openlibrary/package.json +12 -0
  59. package/layers/metadata-openlibrary/server/plugins/openlibrary-register.ts +3 -0
  60. package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/client.ts +30 -0
  61. package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/normalise.ts +81 -0
  62. package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/provider.ts +25 -0
  63. package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/types.ts +48 -0
  64. package/layers/metadata-openlibrary/tsconfig.json +7 -0
  65. package/layers/routing/server/api/feature-flags.get.ts +6 -0
  66. package/layers/scroll/app/plugins/locomotive-scroll.client.ts +6 -1
  67. package/layers/starter/app/app.config.ts +10 -0
  68. package/layers/starter/app/components/StarterHome.vue +34 -25
  69. package/layers/theme/app/components/ThemePicker/Menu.vue +8 -2
  70. package/layers/theme/nuxt.config.ts +1 -1
  71. package/layers/theme/server/plugins/theme-fouc.ts +2 -57
  72. package/layers/theme/server/utils/fouc-config.ts +85 -0
  73. package/package.json +12 -3
  74. package/types/auth.ts +7 -0
  75. package/types/index.ts +2 -0
  76. package/layers/content/app/composables/useCollectionItems.ts +0 -28
@@ -0,0 +1,40 @@
1
+ import type { MetadataSyncInput, MetadataProviderId } from '#layers/metadata/shared/types'
2
+
3
+ // fallow-ignore-next-line complexity
4
+ export default defineEventHandler(async (event) => {
5
+ const body = await readBody<MetadataSyncInput>(event)
6
+
7
+ const { provider: providerName, providerId, resourceType, force } = body ?? {}
8
+ if (!providerName || !providerId) {
9
+ throw createError({ statusCode: 400, message: 'provider and providerId are required' })
10
+ }
11
+
12
+ const provider = getProvider(providerName as MetadataProviderId)
13
+ if (!provider) throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
14
+ if (!provider.sync) throw createError({ statusCode: 400, message: `Provider '${providerName}' does not support sync` })
15
+
16
+ const cacheKey = makeCacheKey('metadata', providerName, resourceType ?? 'item', providerId)
17
+
18
+ if (!force) {
19
+ const cached = await getCacheRecord(cacheKey)
20
+ if (cached) return { synced: false, record: cached.normalised }
21
+ }
22
+
23
+ const result = await provider.sync({ provider: providerName as MetadataProviderId, providerId, resourceType, force })
24
+ const now = new Date().toISOString()
25
+
26
+ await setCacheRecord(cacheKey, {
27
+ id: result.id,
28
+ provider: providerName as MetadataProviderId,
29
+ providerId,
30
+ resourceType: resourceType ?? 'item',
31
+ mediaType: result.mediaType,
32
+ normalised: result,
33
+ raw: result.raw ?? null,
34
+ createdAt: now,
35
+ updatedAt: now,
36
+ lastSyncedAt: now,
37
+ })
38
+
39
+ return { synced: true, record: result }
40
+ })
@@ -0,0 +1,36 @@
1
+ import type { MetadataCacheRecord } from '#layers/metadata/shared/types'
2
+
3
+ // Cache keys: metadata:{provider}:{resourceType}:{providerId}
4
+ // metadata-search:{provider}:{mediaType}:{queryHash}
5
+
6
+ export function makeCacheKey(...parts: string[]): string {
7
+ return parts.join(':')
8
+ }
9
+
10
+ export async function getCacheRecord(key: string): Promise<MetadataCacheRecord | null> {
11
+ const storage = useStorage('metadata')
12
+ return (await storage.getItem<MetadataCacheRecord>(key)) ?? null
13
+ }
14
+
15
+ export async function setCacheRecord(key: string, record: MetadataCacheRecord, ttl?: number): Promise<void> {
16
+ const storage = useStorage('metadata')
17
+ await storage.setItem(key, record, ttl ? { ttl } : undefined)
18
+ }
19
+
20
+ export async function getSearchCache<T>(key: string): Promise<T | null> {
21
+ const storage = useStorage('metadata')
22
+ return (await storage.getItem<T>(key)) ?? null
23
+ }
24
+
25
+ export async function setSearchCache<T>(key: string, value: T, ttl = 3600): Promise<void> {
26
+ const storage = useStorage('metadata')
27
+ await storage.setItem(key, value, { ttl })
28
+ }
29
+
30
+ export function hashQuery(query: string): string {
31
+ let h = 0
32
+ for (let i = 0; i < query.length; i++) {
33
+ h = (Math.imul(31, h) + query.charCodeAt(i)) | 0
34
+ }
35
+ return Math.abs(h).toString(36)
36
+ }
@@ -0,0 +1,24 @@
1
+ export class MetadataProviderError extends Error {
2
+ constructor(
3
+ public readonly provider: string,
4
+ message: string,
5
+ public readonly cause?: unknown,
6
+ ) {
7
+ super(`[metadata:${provider}] ${message}`)
8
+ this.name = 'MetadataProviderError'
9
+ }
10
+ }
11
+
12
+ export class MetadataNotFoundError extends Error {
13
+ constructor(provider: string, id: string) {
14
+ super(`[metadata:${provider}] Not found: ${id}`)
15
+ this.name = 'MetadataNotFoundError'
16
+ }
17
+ }
18
+
19
+ export class MetadataRateLimitError extends Error {
20
+ constructor(provider: string) {
21
+ super(`[metadata:${provider}] Rate limit exceeded`)
22
+ this.name = 'MetadataRateLimitError'
23
+ }
24
+ }
@@ -0,0 +1,15 @@
1
+ import type { MetadataProvider, MetadataProviderId } from '#layers/metadata/shared/types'
2
+
3
+ const _registry = new Map<MetadataProviderId, MetadataProvider>()
4
+
5
+ export function registerProvider(provider: MetadataProvider) {
6
+ _registry.set(provider.id, provider)
7
+ }
8
+
9
+ export function getProvider(id: MetadataProviderId): MetadataProvider | null {
10
+ return _registry.get(id) ?? null
11
+ }
12
+
13
+ export function getProviders(): MetadataProvider[] {
14
+ return [..._registry.values()]
15
+ }
@@ -0,0 +1,42 @@
1
+ import type { MetadataSearchInput, MetadataSearchResult } from '#layers/metadata/shared/types'
2
+
3
+ // fallow-ignore-next-line complexity
4
+ export async function searchMetadata(input: MetadataSearchInput): Promise<MetadataSearchResult[]> {
5
+ const all = getProviders()
6
+ const targets = input.providers?.length
7
+ ? all.filter((p) => input.providers!.includes(p.id))
8
+ : all.filter((p) => !input.mediaType || p.mediaTypes.includes(input.mediaType))
9
+
10
+ if (!targets.length) return []
11
+
12
+ const cacheKey = makeCacheKey(
13
+ 'metadata-search',
14
+ targets.map((p) => p.id).join('+'),
15
+ input.mediaType ?? 'any',
16
+ hashQuery(`${input.query}:${input.limit ?? 10}`),
17
+ )
18
+
19
+ const cached = await getSearchCache<MetadataSearchResult[]>(cacheKey)
20
+ if (cached) return cached
21
+
22
+ const settled = await Promise.allSettled(targets.map((p) => p.search(input)))
23
+
24
+ const results: MetadataSearchResult[] = []
25
+ for (const outcome of settled) {
26
+ if (outcome.status === 'fulfilled') results.push(...outcome.value)
27
+ }
28
+
29
+ const deduped = deduplicateResults(results)
30
+ await setSearchCache(cacheKey, deduped)
31
+ return deduped
32
+ }
33
+
34
+ function deduplicateResults(results: MetadataSearchResult[]): MetadataSearchResult[] {
35
+ const seen = new Set<string>()
36
+ return results.filter((r) => {
37
+ const key = `${r.title.toLowerCase()}:${r.publishedAt?.slice(0, 4) ?? ''}`
38
+ if (seen.has(key)) return false
39
+ seen.add(key)
40
+ return true
41
+ })
42
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../apps/playground/tsconfig.json",
3
+ "compilerOptions": {
4
+ "strict": true
5
+ },
6
+ "exclude": ["node_modules"]
7
+ }
@@ -0,0 +1,36 @@
1
+ export default defineNuxtConfig({
2
+ $meta: {
3
+ name: 'metadata-comicvine',
4
+ },
5
+
6
+ extends: ['../metadata'],
7
+
8
+ alias: {
9
+ '#layers/metadata-comicvine': import.meta.dirname,
10
+ },
11
+
12
+ compatibilityDate: '2026-06-30',
13
+
14
+ runtimeConfig: {
15
+ metadataComicvine: {
16
+ apiKey: '', // env: NUXT_METADATA_COMICVINE_API_KEY
17
+ baseUrl: 'https://comicvine.gamespot.com/api',
18
+ cacheTtl: 60 * 60 * 24 * 30,
19
+ },
20
+ },
21
+
22
+ typescript: {
23
+ typeCheck: false,
24
+ strict: true,
25
+ },
26
+ })
27
+
28
+ declare module '@nuxt/schema' {
29
+ interface RuntimeConfig {
30
+ metadataComicvine: {
31
+ apiKey: string
32
+ baseUrl: string
33
+ cacheTtl: number
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "kmcom-layer-metadata-comicvine",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./nuxt.config.ts",
6
+ "scripts": {
7
+ "dev": "nuxi dev",
8
+ "dev:playground": "PLAYGROUND_LAYERS=core,metadata,metadata-comicvine pnpm -F playground dev",
9
+ "typecheck": "vue-tsc --noEmit -p ../../tsconfig.typecheck.json",
10
+ "lint": "eslint ."
11
+ }
12
+ }
@@ -0,0 +1,3 @@
1
+ export default defineNitroPlugin(() => {
2
+ registerProvider(comicVineProvider)
3
+ })
@@ -0,0 +1,52 @@
1
+ import type { ComicVineApiResponse } from './types'
2
+
3
+ const FIELD_LIST_ISSUE = 'id,name,issue_number,description,deck,cover_date,store_date,site_detail_url,image,volume,person_credits,publisher'
4
+ const FIELD_LIST_VOLUME = 'id,name,description,deck,start_year,site_detail_url,image,publisher,people'
5
+
6
+ function getConfig() {
7
+ const config = useRuntimeConfig()
8
+ return config.metadataComicvine
9
+ }
10
+
11
+ async function comicVineFetch<T>(path: string, params: Record<string, string>): Promise<ComicVineApiResponse<T>> {
12
+ const { apiKey, baseUrl } = getConfig()
13
+
14
+ const url = new URL(`${baseUrl}${path}`)
15
+ url.searchParams.set('api_key', apiKey)
16
+ url.searchParams.set('format', 'json')
17
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
18
+
19
+ const res = await $fetch<ComicVineApiResponse<T>>(url.toString())
20
+ if (res.status_code !== 1) throw new MetadataProviderError('comicvine', `API error: ${res.error}`)
21
+ return res
22
+ }
23
+
24
+ export async function searchComicVineIssues(query: string, limit = 10) {
25
+ return comicVineFetch<import('./types').ComicVineIssue[]>('/search/', {
26
+ query,
27
+ resources: 'issue',
28
+ limit: String(limit),
29
+ field_list: FIELD_LIST_ISSUE,
30
+ })
31
+ }
32
+
33
+ export async function searchComicVineVolumes(query: string, limit = 10) {
34
+ return comicVineFetch<import('./types').ComicVineVolume[]>('/search/', {
35
+ query,
36
+ resources: 'volume',
37
+ limit: String(limit),
38
+ field_list: FIELD_LIST_VOLUME,
39
+ })
40
+ }
41
+
42
+ export async function fetchComicVineIssue(id: string) {
43
+ return comicVineFetch<import('./types').ComicVineIssue>(`/issue/4000-${id}/`, {
44
+ field_list: FIELD_LIST_ISSUE,
45
+ })
46
+ }
47
+
48
+ export async function fetchComicVineVolume(id: string) {
49
+ return comicVineFetch<import('./types').ComicVineVolume>(`/volume/4050-${id}/`, {
50
+ field_list: FIELD_LIST_VOLUME,
51
+ })
52
+ }
@@ -0,0 +1,55 @@
1
+ import type { MetadataRecord, MetadataCreator } from '#layers/metadata/shared/types'
2
+ import type { ComicVineIssue, ComicVineVolume } from './types'
3
+
4
+ // fallow-ignore-next-line complexity
5
+ export function normaliseComicVineIssue(issue: ComicVineIssue): MetadataRecord {
6
+ const creators: MetadataCreator[] = (issue.person_credits ?? []).map((p) => ({
7
+ name: p.name,
8
+ role: p.role,
9
+ providerId: String(p.id),
10
+ }))
11
+
12
+ return {
13
+ id: `comicvine:issue:${issue.id}`,
14
+ provider: 'comicvine',
15
+ providerId: String(issue.id),
16
+ mediaType: 'comic',
17
+ title: issue.volume?.name ? `${issue.volume.name} #${issue.issue_number}` : (issue.name ?? `Issue #${issue.issue_number}`),
18
+ subtitle: issue.name ?? undefined,
19
+ description: issue.deck ?? issue.description ?? undefined,
20
+ creators: creators.length ? creators : undefined,
21
+ publisher: issue.publisher?.name ?? undefined,
22
+ publishedAt: issue.cover_date ?? issue.store_date ?? undefined,
23
+ coverUrl: issue.image?.medium_url ?? issue.image?.original_url ?? undefined,
24
+ identifiers: { comicVineId: String(issue.id) },
25
+ sourceUrl: issue.site_detail_url,
26
+ raw: issue,
27
+ lastSyncedAt: new Date().toISOString(),
28
+ }
29
+ }
30
+
31
+ // fallow-ignore-next-line complexity
32
+ export function normaliseComicVineVolume(volume: ComicVineVolume): MetadataRecord {
33
+ const creators: MetadataCreator[] = (volume.people ?? []).map((p) => ({
34
+ name: p.name,
35
+ role: p.role,
36
+ providerId: String(p.id),
37
+ }))
38
+
39
+ return {
40
+ id: `comicvine:volume:${volume.id}`,
41
+ provider: 'comicvine',
42
+ providerId: String(volume.id),
43
+ mediaType: 'comic',
44
+ title: volume.name,
45
+ description: volume.deck ?? volume.description ?? undefined,
46
+ creators: creators.length ? creators : undefined,
47
+ publisher: volume.publisher?.name ?? undefined,
48
+ publishedAt: volume.start_year ?? undefined,
49
+ coverUrl: volume.image?.medium_url ?? volume.image?.original_url ?? undefined,
50
+ identifiers: { comicVineId: String(volume.id) },
51
+ sourceUrl: volume.site_detail_url,
52
+ raw: volume,
53
+ lastSyncedAt: new Date().toISOString(),
54
+ }
55
+ }
@@ -0,0 +1,39 @@
1
+ import type { MetadataProvider } from '#layers/metadata/shared/types'
2
+
3
+ export const comicVineProvider: MetadataProvider = {
4
+ id: 'comicvine',
5
+ label: 'Comic Vine',
6
+ mediaTypes: ['comic', 'graphic-novel', 'collected-edition'],
7
+
8
+ // fallow-ignore-next-line complexity
9
+ async search({ query, mediaType, limit = 10 }) {
10
+ const resourceType = mediaType === 'collected-edition' || mediaType === 'graphic-novel' ? 'volume' : 'issue'
11
+
12
+ if (resourceType === 'volume') {
13
+ const res = await searchComicVineVolumes(query, limit)
14
+ return (Array.isArray(res.results) ? res.results : []).map(normaliseComicVineVolume)
15
+ }
16
+
17
+ const [issueRes, volumeRes] = await Promise.all([
18
+ searchComicVineIssues(query, Math.ceil(limit / 2)),
19
+ searchComicVineVolumes(query, Math.floor(limit / 2)),
20
+ ])
21
+
22
+ const issues = (Array.isArray(issueRes.results) ? issueRes.results : []).map(normaliseComicVineIssue)
23
+ const volumes = (Array.isArray(volumeRes.results) ? volumeRes.results : []).map(normaliseComicVineVolume)
24
+ return [...issues, ...volumes]
25
+ },
26
+
27
+ async lookup({ providerId, resourceType = 'issue' }) {
28
+ if (resourceType === 'volume') {
29
+ const res = await fetchComicVineVolume(providerId)
30
+ return normaliseComicVineVolume(res.results as import('./types').ComicVineVolume)
31
+ }
32
+ const res = await fetchComicVineIssue(providerId)
33
+ return normaliseComicVineIssue(res.results as import('./types').ComicVineIssue)
34
+ },
35
+
36
+ async sync({ providerId, resourceType = 'issue' }) {
37
+ return (await comicVineProvider.lookup!({ provider: 'comicvine', providerId, resourceType }))!
38
+ },
39
+ }
@@ -0,0 +1,36 @@
1
+ export type ComicVineApiResponse<T> = {
2
+ error: string
3
+ limit: number
4
+ offset: number
5
+ number_of_page_results: number
6
+ number_of_total_results: number
7
+ status_code: number
8
+ results: T
9
+ }
10
+
11
+ export type ComicVineIssue = {
12
+ id: number
13
+ name: string | null
14
+ issue_number: string
15
+ description: string | null
16
+ deck: string | null
17
+ cover_date: string | null
18
+ store_date: string | null
19
+ site_detail_url: string
20
+ image: { original_url: string; medium_url: string } | null
21
+ volume: { id: number; name: string } | null
22
+ person_credits: Array<{ id: number; name: string; role: string }> | null
23
+ publisher: { id: number; name: string } | null
24
+ }
25
+
26
+ export type ComicVineVolume = {
27
+ id: number
28
+ name: string
29
+ description: string | null
30
+ deck: string | null
31
+ start_year: string | null
32
+ site_detail_url: string
33
+ image: { original_url: string; medium_url: string } | null
34
+ publisher: { id: number; name: string } | null
35
+ people: Array<{ id: number; name: string; role: string }> | null
36
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../apps/playground/tsconfig.json",
3
+ "compilerOptions": {
4
+ "strict": true
5
+ },
6
+ "exclude": ["node_modules"]
7
+ }
@@ -0,0 +1,32 @@
1
+ export default defineNuxtConfig({
2
+ $meta: {
3
+ name: 'metadata-google-books',
4
+ },
5
+
6
+ extends: ['../metadata'],
7
+
8
+ alias: {
9
+ '#layers/metadata-google-books': import.meta.dirname,
10
+ },
11
+
12
+ compatibilityDate: '2026-06-30',
13
+
14
+ runtimeConfig: {
15
+ metadataGoogleBooks: {
16
+ apiKey: '', // env: NUXT_METADATA_GOOGLE_BOOKS_API_KEY (optional — anonymous quota is limited)
17
+ },
18
+ },
19
+
20
+ typescript: {
21
+ typeCheck: false,
22
+ strict: true,
23
+ },
24
+ })
25
+
26
+ declare module '@nuxt/schema' {
27
+ interface RuntimeConfig {
28
+ metadataGoogleBooks: {
29
+ apiKey: string
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "kmcom-layer-metadata-google-books",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./nuxt.config.ts",
6
+ "scripts": {
7
+ "dev": "nuxi dev",
8
+ "dev:playground": "PLAYGROUND_LAYERS=core,metadata,metadata-google-books pnpm -F playground dev",
9
+ "typecheck": "vue-tsc --noEmit -p ../../tsconfig.typecheck.json",
10
+ "lint": "eslint ."
11
+ }
12
+ }
@@ -0,0 +1,3 @@
1
+ export default defineNitroPlugin(() => {
2
+ registerProvider(googleBooksProvider)
3
+ })
@@ -0,0 +1,23 @@
1
+ import type { GoogleBooksSearchResponse, GoogleBooksVolume } from './types'
2
+
3
+ const BASE = 'https://www.googleapis.com/books/v1'
4
+
5
+ function getApiKey(): string | undefined {
6
+ return useRuntimeConfig().metadataGoogleBooks?.apiKey || undefined
7
+ }
8
+
9
+ export async function searchGoogleBooks(query: string, limit = 10): Promise<GoogleBooksSearchResponse> {
10
+ const params: Record<string, string> = { q: query, maxResults: String(limit) }
11
+ const apiKey = getApiKey()
12
+ if (apiKey) params.key = apiKey
13
+
14
+ return $fetch<GoogleBooksSearchResponse>(`${BASE}/volumes`, { query: params })
15
+ }
16
+
17
+ export async function fetchGoogleBooksVolume(id: string): Promise<GoogleBooksVolume> {
18
+ const params: Record<string, string> = {}
19
+ const apiKey = getApiKey()
20
+ if (apiKey) params.key = apiKey
21
+
22
+ return $fetch<GoogleBooksVolume>(`${BASE}/volumes/${id}`, { query: params })
23
+ }
@@ -0,0 +1,39 @@
1
+ import type { MetadataRecord, MetadataCreator } from '#layers/metadata/shared/types'
2
+ import type { GoogleBooksVolume } from './types'
3
+
4
+ // fallow-ignore-next-line complexity
5
+ export function normaliseGoogleBooksVolume(volume: GoogleBooksVolume): MetadataRecord {
6
+ const { volumeInfo } = volume
7
+
8
+ const creators: MetadataCreator[] = (volumeInfo.authors ?? []).map((name) => ({
9
+ name,
10
+ role: 'author',
11
+ }))
12
+
13
+ const isbn10 = volumeInfo.industryIdentifiers?.find((i) => i.type === 'ISBN_10')?.identifier
14
+ const isbn13 = volumeInfo.industryIdentifiers?.find((i) => i.type === 'ISBN_13')?.identifier
15
+
16
+ const cover = volumeInfo.imageLinks?.thumbnail ?? volumeInfo.imageLinks?.smallThumbnail
17
+
18
+ return {
19
+ id: `google-books:volume:${volume.id}`,
20
+ provider: 'google-books',
21
+ providerId: volume.id,
22
+ mediaType: 'book',
23
+ 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,
30
+ identifiers: {
31
+ isbn10,
32
+ isbn13,
33
+ googleBooksId: volume.id,
34
+ },
35
+ sourceUrl: volumeInfo.canonicalVolumeLink ?? volumeInfo.infoLink,
36
+ raw: volume,
37
+ lastSyncedAt: new Date().toISOString(),
38
+ }
39
+ }
@@ -0,0 +1,21 @@
1
+ import type { MetadataProvider } from '#layers/metadata/shared/types'
2
+
3
+ export const googleBooksProvider: MetadataProvider = {
4
+ id: 'google-books',
5
+ label: 'Google Books',
6
+ mediaTypes: ['book', 'graphic-novel', 'collected-edition'],
7
+
8
+ async search({ query, limit = 10 }) {
9
+ const res = await searchGoogleBooks(query, limit)
10
+ return (res.items ?? []).map(normaliseGoogleBooksVolume)
11
+ },
12
+
13
+ async lookup({ providerId }) {
14
+ const volume = await fetchGoogleBooksVolume(providerId)
15
+ return normaliseGoogleBooksVolume(volume)
16
+ },
17
+
18
+ async sync({ providerId }) {
19
+ return (await googleBooksProvider.lookup!({ provider: 'google-books', providerId }))!
20
+ },
21
+ }
@@ -0,0 +1,25 @@
1
+ export type GoogleBooksVolumeInfo = {
2
+ title: string
3
+ subtitle?: string
4
+ authors?: string[]
5
+ publisher?: string
6
+ publishedDate?: string
7
+ description?: string
8
+ industryIdentifiers?: Array<{ type: 'ISBN_10' | 'ISBN_13' | string; identifier: string }>
9
+ pageCount?: number
10
+ imageLinks?: { thumbnail?: string; smallThumbnail?: string }
11
+ previewLink?: string
12
+ infoLink?: string
13
+ canonicalVolumeLink?: string
14
+ }
15
+
16
+ export type GoogleBooksVolume = {
17
+ id: string
18
+ selfLink: string
19
+ volumeInfo: GoogleBooksVolumeInfo
20
+ }
21
+
22
+ export type GoogleBooksSearchResponse = {
23
+ totalItems: number
24
+ items?: GoogleBooksVolume[]
25
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../apps/playground/tsconfig.json",
3
+ "compilerOptions": {
4
+ "strict": true
5
+ },
6
+ "exclude": ["node_modules"]
7
+ }
@@ -0,0 +1,18 @@
1
+ export default defineNuxtConfig({
2
+ $meta: {
3
+ name: 'metadata-openlibrary',
4
+ },
5
+
6
+ extends: ['../metadata'],
7
+
8
+ alias: {
9
+ '#layers/metadata-openlibrary': import.meta.dirname,
10
+ },
11
+
12
+ compatibilityDate: '2026-06-30',
13
+
14
+ typescript: {
15
+ typeCheck: false,
16
+ strict: true,
17
+ },
18
+ })
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "kmcom-layer-metadata-openlibrary",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./nuxt.config.ts",
6
+ "scripts": {
7
+ "dev": "nuxi dev",
8
+ "dev:playground": "PLAYGROUND_LAYERS=core,metadata,metadata-openlibrary pnpm -F playground dev",
9
+ "typecheck": "vue-tsc --noEmit -p ../../tsconfig.typecheck.json",
10
+ "lint": "eslint ."
11
+ }
12
+ }
@@ -0,0 +1,3 @@
1
+ export default defineNitroPlugin(() => {
2
+ registerProvider(openLibraryProvider)
3
+ })
@@ -0,0 +1,30 @@
1
+ import type { OpenLibrarySearchResponse, OpenLibraryWork, OpenLibraryEdition } from './types'
2
+
3
+ const BASE = 'https://openlibrary.org'
4
+
5
+ export function openLibraryCoverUrl(coverId: number, size: 'S' | 'M' | 'L' = 'M'): string {
6
+ return `https://covers.openlibrary.org/b/id/${coverId}-${size}.jpg`
7
+ }
8
+
9
+ export async function searchOpenLibrary(query: string, limit = 10): Promise<OpenLibrarySearchResponse> {
10
+ 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' },
12
+ })
13
+ }
14
+
15
+ export async function lookupOpenLibraryWork(workId: string): Promise<OpenLibraryWork> {
16
+ return $fetch<OpenLibraryWork>(`${BASE}/works/${workId}.json`)
17
+ }
18
+
19
+ export async function lookupOpenLibraryEdition(editionId: string): Promise<OpenLibraryEdition> {
20
+ return $fetch<OpenLibraryEdition>(`${BASE}/books/${editionId}.json`)
21
+ }
22
+
23
+ export async function lookupByIsbn(isbn: string): Promise<OpenLibraryEdition | null> {
24
+ try {
25
+ return await $fetch<OpenLibraryEdition>(`${BASE}/isbn/${isbn}.json`)
26
+ }
27
+ catch {
28
+ return null
29
+ }
30
+ }