kmcom-nuxt-layers 5.0.8 → 6.1.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 (61) hide show
  1. package/docs/adr/0001-grid-replaces-layout-swiss-grid.md +30 -0
  2. package/docs/adr/0002-content-config-is-app-owned.md +27 -0
  3. package/docs/adr/0003-modular-step-system-replaces-fluid-clamps.md +58 -0
  4. package/layers/core/app/assets/css/tokens/spacing-fluid.css +24 -19
  5. package/layers/core/app/components/Element.test.ts +2 -2
  6. package/layers/core/app/components/Element.vue +2 -66
  7. package/layers/core/app/components/ElementTw.vue +2 -66
  8. package/layers/core/app/composables/useElementStyle.ts +5 -59
  9. package/layers/core/app/composables/useLayoutAttrs.test.ts +35 -5
  10. package/layers/core/app/composables/useLayoutAttrs.ts +21 -21
  11. package/layers/core/app/types/element.ts +41 -18
  12. package/layers/core/app/utils/resolveElementStyle.test.ts +99 -0
  13. package/layers/core/app/utils/resolveElementStyle.ts +52 -0
  14. package/layers/data/metadata/providers/comicvine/server/utils/metadata-comicvine/client.ts +1 -0
  15. package/layers/data/metadata/providers/comicvine/server/utils/metadata-comicvine/provider.ts +0 -10
  16. package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/client.ts +12 -11
  17. package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/provider.ts +0 -7
  18. package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.ts +12 -23
  19. package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/provider.ts +0 -10
  20. package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.ts +4 -7
  21. package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/provider.ts +0 -6
  22. package/layers/data/metadata/server/api/metadata/lookup.get.ts +7 -42
  23. package/layers/data/metadata/server/api/metadata/sync.post.ts +4 -49
  24. package/layers/data/metadata/server/utils/metadata/provider-fetch.test.ts +27 -0
  25. package/layers/data/metadata/server/utils/metadata/provider-fetch.ts +14 -0
  26. package/layers/data/metadata/server/utils/metadata/resolve-record.test.ts +87 -0
  27. package/layers/data/metadata/server/utils/metadata/resolve-record.ts +60 -0
  28. package/layers/data/metadata/shared/types/providers.ts +3 -2
  29. package/layers/design-system/theming/app/types/designSystem.ts +21 -17
  30. package/layers/design-system/theming/nuxt.config.ts +1 -1
  31. package/layers/design-system/typography/app/app.config.ts +26 -0
  32. package/layers/design-system/typography/app/assets/css/typography.css +216 -187
  33. package/layers/design-system/typography/app/components/Typography/CodeBlock.vue +5 -5
  34. package/layers/design-system/typography/app/components/Typography/Headline.test.ts +36 -0
  35. package/layers/design-system/typography/app/components/Typography/Headline.vue +21 -22
  36. package/layers/design-system/typography/app/components/Typography/QuoteBlock.vue +5 -5
  37. package/layers/design-system/typography/app/components/Typography/RepeatText.vue +7 -6
  38. package/layers/design-system/typography/app/components/Typography/index.test.ts +31 -0
  39. package/layers/design-system/typography/app/components/Typography/index.vue +11 -9
  40. package/layers/design-system/typography/app/composables/color.test.ts +30 -0
  41. package/layers/design-system/typography/app/composables/color.ts +9 -2
  42. package/layers/design-system/typography/app/composables/typography.test.ts +53 -0
  43. package/layers/design-system/typography/app/composables/typography.ts +28 -43
  44. package/layers/design-system/typography/app/types/typography.ts +5 -32
  45. package/layers/design-system/visual/app/composables/tint.test.ts +22 -0
  46. package/layers/design-system/visual/app/composables/tint.ts +6 -7
  47. package/layers/render/shader/nuxt.config.ts +1 -1
  48. package/layers/structure/grid/app/assets/css/grid.css +11 -7
  49. package/layers/structure/navigation/app/components/Links/Named.vue +1 -1
  50. package/package.json +5 -4
  51. package/layers/core/app/composables/useElementGrid.test.ts +0 -33
  52. package/layers/core/app/composables/useElementGrid.ts +0 -29
  53. package/layers/core/app/composables/useElementInteraction.test.ts +0 -27
  54. package/layers/core/app/composables/useElementInteraction.ts +0 -17
  55. package/layers/core/app/composables/useElementLayout.test.ts +0 -41
  56. package/layers/core/app/composables/useElementLayout.ts +0 -26
  57. package/layers/core/app/composables/useElementSizing.test.ts +0 -39
  58. package/layers/core/app/composables/useElementSizing.ts +0 -25
  59. package/layers/core/app/composables/useElementSurface.test.ts +0 -33
  60. package/layers/core/app/composables/useElementSurface.ts +0 -21
  61. package/layers/design-system/theming/app/composables/useThemePreferences.ts +0 -5
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from 'vite-plus/test'
2
+
3
+ import { resolveElementStyle } from './resolveElementStyle'
4
+
5
+ describe('resolveElementStyle display mode', () => {
6
+ it('returns no display when no mode prop is set', () => {
7
+ expect(resolveElementStyle({}).display).toBeUndefined()
8
+ })
9
+
10
+ it.each([
11
+ ['block', 'block'],
12
+ ['flex', 'flex'],
13
+ ['grid', 'grid'],
14
+ ['hidden', 'none'],
15
+ ] as const)('resolves %s to display: %s', (prop, display) => {
16
+ expect(resolveElementStyle({ [prop]: true }).display).toBe(display)
17
+ })
18
+
19
+ it('prioritizes hidden > grid > flex > block', () => {
20
+ expect(resolveElementStyle({ hidden: true, grid: true, flex: true, block: true }).display).toBe(
21
+ 'none'
22
+ )
23
+ expect(resolveElementStyle({ grid: true, flex: true, block: true }).display).toBe('grid')
24
+ expect(resolveElementStyle({ flex: true, block: true }).display).toBe('flex')
25
+ })
26
+ })
27
+
28
+ describe('resolveElementStyle grid container axis', () => {
29
+ it('returns no grid-template properties when grid is not set', () => {
30
+ const style = resolveElementStyle({ cols: 12 })
31
+ expect(style.gridTemplateColumns).toBeUndefined()
32
+ expect(style.gridTemplateRows).toBeUndefined()
33
+ })
34
+
35
+ it('converts numeric cols/rows to repeat() tracks when grid is set', () => {
36
+ const style = resolveElementStyle({ grid: true, cols: 12, rows: 4 })
37
+ expect(style.gridTemplateColumns).toBe('repeat(12, 1fr)')
38
+ expect(style.gridTemplateRows).toBe('repeat(4, 1fr)')
39
+ })
40
+
41
+ it('passes a string track through unchanged', () => {
42
+ expect(resolveElementStyle({ grid: true, cols: '1fr 2fr 1fr' }).gridTemplateColumns).toBe(
43
+ '1fr 2fr 1fr'
44
+ )
45
+ })
46
+ })
47
+
48
+ describe('resolveElementStyle prop mapping', () => {
49
+ it('maps sizing, surface and interaction props to their CSS properties 1:1', () => {
50
+ expect(
51
+ resolveElementStyle({
52
+ w: '10px',
53
+ h: '20px',
54
+ minW: '1px',
55
+ maxW: '2px',
56
+ minH: '3px',
57
+ maxH: '4px',
58
+ aspect: '16 / 9',
59
+ bg: 'red',
60
+ color: 'blue',
61
+ border: '1px solid',
62
+ radius: '4px',
63
+ shadow: '0 0 1px black',
64
+ cursor: 'pointer',
65
+ select: 'none',
66
+ pointer: 'none',
67
+ })
68
+ ).toMatchObject({
69
+ width: '10px',
70
+ height: '20px',
71
+ minWidth: '1px',
72
+ maxWidth: '2px',
73
+ minHeight: '3px',
74
+ maxHeight: '4px',
75
+ aspectRatio: '16 / 9',
76
+ background: 'red',
77
+ color: 'blue',
78
+ border: '1px solid',
79
+ borderRadius: '4px',
80
+ boxShadow: '0 0 1px black',
81
+ cursor: 'pointer',
82
+ userSelect: 'none',
83
+ pointerEvents: 'none',
84
+ })
85
+ })
86
+
87
+ it('merges layout attrs (gap/align/padding) into the same object', () => {
88
+ expect(resolveElementStyle({ flex: true, gap: 'md', align: 'center', p: 'sm' })).toMatchObject({
89
+ display: 'flex',
90
+ gap: 'var(--space-md)',
91
+ alignItems: 'center',
92
+ padding: 'var(--space-sm)',
93
+ })
94
+ })
95
+
96
+ it('leaves every property undefined when no props are set', () => {
97
+ expect(Object.values(resolveElementStyle({})).every((v) => v === undefined)).toBe(true)
98
+ })
99
+ })
@@ -0,0 +1,52 @@
1
+ import type { CSSProperties } from 'vue'
2
+
3
+ import { resolveLayoutAttrsStyle } from '../composables/useLayoutAttrs'
4
+ import type { ElementProps } from '../types/element'
5
+
6
+ export type ElementStyleInput = Omit<ElementProps, 'as' | 'componentProps'>
7
+
8
+ const DISPLAY_FOR = {
9
+ hidden: 'none',
10
+ grid: 'grid',
11
+ flex: 'flex',
12
+ block: 'block',
13
+ } as const
14
+
15
+ function track(value: number | string | undefined): string | undefined {
16
+ return typeof value === 'number' ? `repeat(${value}, 1fr)` : value
17
+ }
18
+
19
+ /**
20
+ * Element's whole prop vocabulary → one CSSProperties object. Pure: shared by Element
21
+ * (inline :style) and ElementTw (Tailwind arbitrary-property classes).
22
+ *
23
+ * Grid is container axis only ("I am a grid"). Item placement lives in the layout layer
24
+ * (buildGridPlacementStyle); Element is in core and can't depend on layout.
25
+ */
26
+ export function resolveElementStyle(props: ElementStyleInput): CSSProperties {
27
+ // hidden > grid > flex > block
28
+ const mode = (['hidden', 'grid', 'flex', 'block'] as const).find((m) => props[m])
29
+ return {
30
+ display: mode && DISPLAY_FOR[mode],
31
+ ...resolveLayoutAttrsStyle(props),
32
+ ...(props.grid && {
33
+ gridTemplateColumns: track(props.cols),
34
+ gridTemplateRows: track(props.rows),
35
+ }),
36
+ width: props.w,
37
+ height: props.h,
38
+ minWidth: props.minW,
39
+ maxWidth: props.maxW,
40
+ minHeight: props.minH,
41
+ maxHeight: props.maxH,
42
+ aspectRatio: props.aspect,
43
+ background: props.bg,
44
+ color: props.color,
45
+ border: props.border,
46
+ borderRadius: props.radius,
47
+ boxShadow: props.shadow,
48
+ cursor: props.cursor,
49
+ userSelect: props.select,
50
+ pointerEvents: props.pointer,
51
+ }
52
+ }
@@ -1,3 +1,4 @@
1
+ import { MetadataProviderError } from '#layers/metadata/server/utils/metadata/errors'
1
2
  import { useRuntimeConfig } from 'nitropack/runtime'
2
3
  import type { ComicVineApiResponse, ComicVineIssue, ComicVineVolume } from './types'
3
4
 
@@ -58,14 +58,4 @@ export const comicVineProvider: MetadataProvider = {
58
58
  const res = await fetchComicVineIssue(providerId)
59
59
  return normaliseComicVineIssue(res.results as ComicVineIssue)
60
60
  },
61
-
62
- async sync({ providerId, resourceType = 'issue' }) {
63
- const record = await comicVineProvider.lookup({
64
- provider: 'comicvine',
65
- providerId,
66
- resourceType,
67
- })
68
- if (!record) throw new MetadataProviderError('comicvine', `No record found for ${providerId}`)
69
- return record
70
- },
71
61
  }
@@ -1,3 +1,4 @@
1
+ import { providerFetch } from '#layers/metadata/server/utils/metadata/provider-fetch'
1
2
  import { useRuntimeConfig } from 'nitropack/runtime'
2
3
  import type { MetadataMediaType } from '#layers/metadata/shared/types'
3
4
 
@@ -29,15 +30,17 @@ export async function searchGoogleBooks(
29
30
  query: string,
30
31
  limit = 10
31
32
  ): Promise<GoogleBooksSearchResponse> {
32
- const params: Record<string, string> = { q: query, maxResults: String(limit), langRestrict: 'en' }
33
+ const params: Record<string, string> = {
34
+ q: query,
35
+ maxResults: String(limit),
36
+ langRestrict: 'en',
37
+ }
33
38
  const apiKey = getApiKey()
34
39
  if (apiKey) params.key = apiKey
35
40
 
36
- try {
37
- return await $fetch<GoogleBooksSearchResponse>(`${BASE}/volumes`, { query: params })
38
- } catch (err) {
39
- throw new MetadataProviderError('google-books', `API error: ${String(err)}`)
40
- }
41
+ return providerFetch<GoogleBooksSearchResponse>('google-books', `${BASE}/volumes`, {
42
+ query: params,
43
+ })
41
44
  }
42
45
 
43
46
  export async function fetchGoogleBooksVolume(id: string): Promise<GoogleBooksVolume> {
@@ -45,9 +48,7 @@ export async function fetchGoogleBooksVolume(id: string): Promise<GoogleBooksVol
45
48
  const apiKey = getApiKey()
46
49
  if (apiKey) params.key = apiKey
47
50
 
48
- try {
49
- return await $fetch<GoogleBooksVolume>(`${BASE}/volumes/${id}`, { query: params })
50
- } catch (err) {
51
- throw new MetadataProviderError('google-books', `API error: ${String(err)}`)
52
- }
51
+ return providerFetch<GoogleBooksVolume>('google-books', `${BASE}/volumes/${id}`, {
52
+ query: params,
53
+ })
53
54
  }
@@ -23,11 +23,4 @@ export const googleBooksProvider: MetadataProvider = {
23
23
  const volume = await fetchGoogleBooksVolume(providerId)
24
24
  return normaliseGoogleBooksVolume(volume)
25
25
  },
26
-
27
- async sync({ providerId }) {
28
- const record = await googleBooksProvider.lookup({ provider: 'google-books', providerId })
29
- if (!record)
30
- throw new MetadataProviderError('google-books', `No record found for ${providerId}`)
31
- return record
32
- },
33
26
  }
@@ -1,3 +1,4 @@
1
+ import { providerFetch } from '#layers/metadata/server/utils/metadata/provider-fetch'
1
2
  import type { OpenLibraryEdition, OpenLibrarySearchResponse, OpenLibraryWork } from './types'
2
3
 
3
4
  const BASE = 'https://openlibrary.org'
@@ -10,35 +11,23 @@ export async function searchOpenLibrary(
10
11
  query: string,
11
12
  limit = 10
12
13
  ): Promise<OpenLibrarySearchResponse> {
13
- try {
14
- return await $fetch<OpenLibrarySearchResponse>(`${BASE}/search.json`, {
15
- timeout: 10000,
16
- query: {
17
- q: query,
18
- limit,
19
- fields:
20
- 'key,title,subtitle,author_name,author_key,publisher,first_publish_year,isbn,cover_i,number_of_pages_median',
21
- },
22
- })
23
- } catch (err) {
24
- throw new MetadataProviderError('openlibrary', `API error: ${String(err)}`)
25
- }
14
+ return providerFetch<OpenLibrarySearchResponse>('openlibrary', `${BASE}/search.json`, {
15
+ timeout: 10000,
16
+ query: {
17
+ q: query,
18
+ limit,
19
+ fields:
20
+ 'key,title,subtitle,author_name,author_key,publisher,first_publish_year,isbn,cover_i,number_of_pages_median',
21
+ },
22
+ })
26
23
  }
27
24
 
28
25
  export async function lookupOpenLibraryWork(workId: string): Promise<OpenLibraryWork> {
29
- try {
30
- return await $fetch<OpenLibraryWork>(`${BASE}/works/${workId}.json`)
31
- } catch (err) {
32
- throw new MetadataProviderError('openlibrary', `API error: ${String(err)}`)
33
- }
26
+ return providerFetch<OpenLibraryWork>('openlibrary', `${BASE}/works/${workId}.json`)
34
27
  }
35
28
 
36
29
  export async function lookupOpenLibraryEdition(editionId: string): Promise<OpenLibraryEdition> {
37
- try {
38
- return await $fetch<OpenLibraryEdition>(`${BASE}/books/${editionId}.json`)
39
- } catch (err) {
40
- throw new MetadataProviderError('openlibrary', `API error: ${String(err)}`)
41
- }
30
+ return providerFetch<OpenLibraryEdition>('openlibrary', `${BASE}/books/${editionId}.json`)
42
31
  }
43
32
 
44
33
  export async function lookupByIsbn(isbn: string): Promise<OpenLibraryEdition | null> {
@@ -24,14 +24,4 @@ export const openLibraryProvider: MetadataProvider = {
24
24
  const work = await lookupOpenLibraryWork(providerId)
25
25
  return normaliseOpenLibraryWork(work)
26
26
  },
27
-
28
- async sync({ providerId, resourceType = 'work' }) {
29
- const record = await openLibraryProvider.lookup({
30
- provider: 'openlibrary',
31
- providerId,
32
- resourceType,
33
- })
34
- if (!record) throw new MetadataProviderError('openlibrary', `No record found for ${providerId}`)
35
- return record
36
- },
37
27
  }
@@ -1,3 +1,4 @@
1
+ import { providerFetch } from '#layers/metadata/server/utils/metadata/provider-fetch'
1
2
  import { useRuntimeConfig } from 'nitropack/runtime'
2
3
  import type { TMDBFindResponse, TMDBMovie, TMDBSearchResponse, TMDBTVShow } from './types'
3
4
 
@@ -22,13 +23,9 @@ async function tmdbFetch<T>(path: string, params: Record<string, string> = {}):
22
23
  if (!isJwt) url.searchParams.set('api_key', apiKey)
23
24
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
24
25
 
25
- try {
26
- return await $fetch<T>(url.toString(), {
27
- ...(isJwt && { headers: { Authorization: `Bearer ${apiKey}` } }),
28
- })
29
- } catch (err) {
30
- throw new MetadataProviderError('tmdb', `API error: ${String(err)}`)
31
- }
26
+ return providerFetch<T>('tmdb', url.toString(), {
27
+ ...(isJwt && { headers: { Authorization: `Bearer ${apiKey}` } }),
28
+ })
32
29
  }
33
30
 
34
31
  export async function searchTmdb(query: string, limit = 10): Promise<TMDBSearchResponse> {
@@ -32,10 +32,4 @@ export const tmdbProvider: MetadataProvider = {
32
32
  if (resourceType === 'tv-show') return normaliseTmdbTV(await fetchTmdbTV(providerId))
33
33
  return normaliseTmdbMovie(await fetchTmdbMovie(providerId))
34
34
  },
35
-
36
- async sync(input) {
37
- const record = await tmdbProvider.lookup(input)
38
- if (!record) throw new MetadataProviderError('tmdb', `No record found for ${input.providerId}`)
39
- return record
40
- },
41
35
  }
@@ -1,48 +1,13 @@
1
- import { getCacheRecord, makeCacheKey, setCacheRecord } from '../../utils/metadata/cache'
2
- import { getProvider } from '../../utils/metadata/provider-registry'
3
- import { createError, defineEventHandler, getQuery } from 'h3'
4
- import type { MetadataProviderId } from '#layers/metadata/shared/types'
1
+ import { resolveRecord } from '../../utils/metadata/resolve-record'
2
+ import { defineEventHandler, getQuery } from 'h3'
5
3
 
6
- // fallow-ignore-next-line complexity
7
4
  export default defineEventHandler(async (event) => {
8
5
  const query = getQuery(event)
9
6
 
10
- const providerId = String(query.providerId ?? '').trim()
11
- const providerName = String(query.provider ?? '').trim() as MetadataProviderId
12
- const resourceType = query.resourceType ? String(query.resourceType) : undefined
13
-
14
- if (!providerName || !providerId) {
15
- throw createError({ statusCode: 400, message: 'provider and providerId are required' })
16
- }
17
-
18
- const provider = getProvider(providerName)
19
- if (!provider)
20
- throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
21
-
22
- const cacheKey = makeCacheKey('metadata', providerName, resourceType ?? 'item', providerId)
23
- const cached = await getCacheRecord(cacheKey)
24
- if (cached) return cached.normalised
25
-
26
- const result = await provider.lookup({
27
- provider: providerName,
28
- providerId,
29
- ...(resourceType && { resourceType }),
7
+ const { record } = await resolveRecord({
8
+ provider: String(query.provider ?? '').trim(),
9
+ providerId: String(query.providerId ?? '').trim(),
10
+ ...(query.resourceType && { resourceType: String(query.resourceType) }),
30
11
  })
31
- if (!result) throw createError({ statusCode: 404, message: 'Not found' })
32
-
33
- const now = new Date().toISOString()
34
- await setCacheRecord(cacheKey, {
35
- id: result.id,
36
- provider: providerName,
37
- providerId,
38
- resourceType: resourceType ?? 'item',
39
- mediaType: result.mediaType,
40
- normalised: result,
41
- raw: result.raw ?? null,
42
- createdAt: now,
43
- updatedAt: now,
44
- lastSyncedAt: now,
45
- })
46
-
47
- return result
12
+ return record
48
13
  })
@@ -1,53 +1,8 @@
1
- import { getCacheRecord, makeCacheKey, setCacheRecord } from '../../utils/metadata/cache'
2
- import { getProvider } from '../../utils/metadata/provider-registry'
3
- import { createError, defineEventHandler, readBody } from 'h3'
4
- import type { MetadataProviderId, MetadataSyncInput } from '#layers/metadata/shared/types'
1
+ import { resolveRecord } from '../../utils/metadata/resolve-record'
2
+ import { defineEventHandler, readBody } from 'h3'
3
+ import type { MetadataSyncInput } from '#layers/metadata/shared/types'
5
4
 
6
- // fallow-ignore-next-line complexity
7
5
  export default defineEventHandler(async (event) => {
8
6
  const body = await readBody<MetadataSyncInput>(event)
9
-
10
- const { provider: providerName, providerId, resourceType, force } = body ?? {}
11
- if (!providerName || !providerId) {
12
- throw createError({ statusCode: 400, message: 'provider and providerId are required' })
13
- }
14
-
15
- const provider = getProvider(providerName as MetadataProviderId)
16
- if (!provider)
17
- throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
18
- if (!provider.sync)
19
- throw createError({
20
- statusCode: 400,
21
- message: `Provider '${providerName}' does not support sync`,
22
- })
23
-
24
- const cacheKey = makeCacheKey('metadata', providerName, resourceType ?? 'item', providerId)
25
-
26
- if (!force) {
27
- const cached = await getCacheRecord(cacheKey)
28
- if (cached) return { synced: false, record: cached.normalised }
29
- }
30
-
31
- const result = await provider.sync({
32
- provider: providerName as MetadataProviderId,
33
- providerId,
34
- ...(resourceType && { resourceType }),
35
- ...(force !== undefined && { force }),
36
- })
37
- const now = new Date().toISOString()
38
-
39
- await setCacheRecord(cacheKey, {
40
- id: result.id,
41
- provider: providerName as MetadataProviderId,
42
- providerId,
43
- resourceType: resourceType ?? 'item',
44
- mediaType: result.mediaType,
45
- normalised: result,
46
- raw: result.raw ?? null,
47
- createdAt: now,
48
- updatedAt: now,
49
- lastSyncedAt: now,
50
- })
51
-
52
- return { synced: true, record: result }
7
+ return resolveRecord({ ...body })
53
8
  })
@@ -0,0 +1,27 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
2
+
3
+ import { MetadataProviderError } from './errors'
4
+ import { providerFetch } from './provider-fetch'
5
+
6
+ afterEach(() => vi.unstubAllGlobals())
7
+
8
+ describe('providerFetch', () => {
9
+ it('returns the fetched body', async () => {
10
+ vi.stubGlobal(
11
+ '$fetch',
12
+ vi.fn(async () => ({ ok: true }))
13
+ )
14
+ await expect(providerFetch('tmdb', 'https://x.test')).resolves.toEqual({ ok: true })
15
+ })
16
+
17
+ it('wraps failures in a MetadataProviderError for the provider', async () => {
18
+ vi.stubGlobal(
19
+ '$fetch',
20
+ vi.fn(async () => Promise.reject(new Error('boom')))
21
+ )
22
+ const err = await providerFetch('tmdb', 'https://x.test').catch((e) => e)
23
+ expect(err).toBeInstanceOf(MetadataProviderError)
24
+ expect(err.provider).toBe('tmdb')
25
+ expect(err.message).toContain('API error')
26
+ })
27
+ })
@@ -0,0 +1,14 @@
1
+ import { MetadataProviderError } from './errors'
2
+
3
+ /** `$fetch` that reports any failure as a MetadataProviderError for `provider`. */
4
+ export async function providerFetch<T>(
5
+ provider: string,
6
+ url: string,
7
+ opts?: { query?: Record<string, unknown>; headers?: Record<string, string>; timeout?: number }
8
+ ): Promise<T> {
9
+ try {
10
+ return (await $fetch(url, opts)) as T
11
+ } catch (err) {
12
+ throw new MetadataProviderError(provider, `API error: ${String(err)}`, err)
13
+ }
14
+ }
@@ -0,0 +1,87 @@
1
+ import type { MetadataProvider, MetadataRecord } from '#layers/metadata/shared/types'
2
+ import type * as CacheModule from './cache'
3
+ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
4
+
5
+ const { providerState, getCacheRecordMock, setCacheRecordMock } = vi.hoisted(() => ({
6
+ providerState: { provider: null as MetadataProvider | null },
7
+ getCacheRecordMock: vi.fn(async () => null as { normalised: MetadataRecord } | null),
8
+ setCacheRecordMock: vi.fn(async () => undefined),
9
+ }))
10
+
11
+ vi.mock('./provider-registry', () => ({ getProvider: () => providerState.provider }))
12
+ vi.mock('./cache', async (importOriginal) => ({
13
+ ...(await importOriginal<typeof CacheModule>()),
14
+ getCacheRecord: getCacheRecordMock,
15
+ setCacheRecord: setCacheRecordMock,
16
+ }))
17
+
18
+ import { resolveRecord } from './resolve-record'
19
+
20
+ const record = {
21
+ id: 'r1',
22
+ provider: 'comicvine',
23
+ providerId: '42',
24
+ mediaType: 'comic',
25
+ title: 'Watchmen',
26
+ } as MetadataRecord
27
+
28
+ const lookup = vi.fn(async () => record as MetadataRecord | null)
29
+
30
+ beforeEach(() => {
31
+ vi.clearAllMocks()
32
+ getCacheRecordMock.mockResolvedValue(null)
33
+ lookup.mockResolvedValue(record)
34
+ providerState.provider = {
35
+ id: 'comicvine',
36
+ label: 'Comic Vine',
37
+ mediaTypes: ['comic'],
38
+ search: async () => [],
39
+ lookup,
40
+ }
41
+ })
42
+
43
+ describe('resolveRecord', () => {
44
+ it('400s without provider or providerId', async () => {
45
+ await expect(resolveRecord({ provider: 'comicvine' })).rejects.toMatchObject({
46
+ statusCode: 400,
47
+ })
48
+ })
49
+
50
+ it('404s for an unregistered provider', async () => {
51
+ providerState.provider = null
52
+ await expect(resolveRecord({ provider: 'nope', providerId: '1' })).rejects.toMatchObject({
53
+ statusCode: 404,
54
+ })
55
+ })
56
+
57
+ it('fetches, caches and reports synced on a miss', async () => {
58
+ const out = await resolveRecord({ provider: 'comicvine', providerId: '42' })
59
+ expect(out).toEqual({ record, synced: true })
60
+ expect(setCacheRecordMock).toHaveBeenCalledWith(
61
+ 'metadata:comicvine:item:42',
62
+ expect.objectContaining({ id: 'r1', providerId: '42', resourceType: 'item' })
63
+ )
64
+ })
65
+
66
+ it('returns the cached record without calling the provider', async () => {
67
+ getCacheRecordMock.mockResolvedValue({ normalised: record })
68
+ const out = await resolveRecord({ provider: 'comicvine', providerId: '42' })
69
+ expect(out).toEqual({ record, synced: false })
70
+ expect(lookup).not.toHaveBeenCalled()
71
+ })
72
+
73
+ it('force skips the cache read', async () => {
74
+ getCacheRecordMock.mockResolvedValue({ normalised: record })
75
+ const out = await resolveRecord({ provider: 'comicvine', providerId: '42', force: true })
76
+ expect(out.synced).toBe(true)
77
+ expect(lookup).toHaveBeenCalledOnce()
78
+ })
79
+
80
+ it('404s when the provider finds nothing and does not cache', async () => {
81
+ lookup.mockResolvedValue(null)
82
+ await expect(resolveRecord({ provider: 'comicvine', providerId: '42' })).rejects.toMatchObject({
83
+ statusCode: 404,
84
+ })
85
+ expect(setCacheRecordMock).not.toHaveBeenCalled()
86
+ })
87
+ })
@@ -0,0 +1,60 @@
1
+ import { createError } from 'h3'
2
+ import { getCacheRecord, makeCacheKey, setCacheRecord } from './cache'
3
+ import { getProvider } from './provider-registry'
4
+ import type { MetadataRecord } from '#layers/metadata/shared/types'
5
+
6
+ export type ResolveRecordInput = {
7
+ provider?: string
8
+ providerId?: string
9
+ resourceType?: string
10
+ /** Skip the cache read and refetch from the provider. */
11
+ force?: boolean
12
+ }
13
+
14
+ /**
15
+ * Cache-through lookup of one record. `synced` is true when the provider was called,
16
+ * false when the cached record was returned. Throws h3 errors (400/404) for bad input.
17
+ */
18
+ export async function resolveRecord(
19
+ input: ResolveRecordInput
20
+ ): Promise<{ record: MetadataRecord; synced: boolean }> {
21
+ const { provider: providerName, providerId, resourceType, force } = input
22
+ if (!providerName || !providerId) {
23
+ throw createError({ statusCode: 400, message: 'provider and providerId are required' })
24
+ }
25
+
26
+ const provider = getProvider(providerName)
27
+ if (!provider) {
28
+ throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
29
+ }
30
+
31
+ const cacheKey = makeCacheKey('metadata', providerName, resourceType ?? 'item', providerId)
32
+
33
+ if (!force) {
34
+ const cached = await getCacheRecord(cacheKey)
35
+ if (cached) return { record: cached.normalised, synced: false }
36
+ }
37
+
38
+ const record = await provider.lookup({
39
+ provider: providerName,
40
+ providerId,
41
+ ...(resourceType && { resourceType }),
42
+ })
43
+ if (!record) throw createError({ statusCode: 404, message: 'Not found' })
44
+
45
+ const now = new Date().toISOString()
46
+ await setCacheRecord(cacheKey, {
47
+ id: record.id,
48
+ provider: providerName,
49
+ providerId,
50
+ resourceType: resourceType ?? 'item',
51
+ mediaType: record.mediaType,
52
+ normalised: record,
53
+ raw: record.raw ?? null,
54
+ createdAt: now,
55
+ updatedAt: now,
56
+ lastSyncedAt: now,
57
+ })
58
+
59
+ return { record, synced: true }
60
+ }
@@ -1,6 +1,8 @@
1
1
  import type { MetadataMediaType, MetadataRecord } from './metadata'
2
2
 
3
- export type MetadataProviderId = 'comicvine' | 'openlibrary' | 'google-books' | 'tmdb' | 'local'
3
+ // Open: a provider layer registers whatever id it likes (built-ins: comicvine, openlibrary,
4
+ // google-books, tmdb). Unknown ids are rejected at runtime by the provider registry.
5
+ export type MetadataProviderId = string
4
6
 
5
7
  export type MetadataSearchInput = {
6
8
  query: string
@@ -32,5 +34,4 @@ export type MetadataProvider = {
32
34
  mediaTypes: MetadataMediaType[]
33
35
  search: (input: MetadataSearchInput) => Promise<MetadataSearchResult[]>
34
36
  lookup: (input: MetadataLookupInput) => Promise<MetadataRecord | null>
35
- sync?: (input: MetadataSyncInput) => Promise<MetadataRecord>
36
37
  }