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.
- package/docs/adr/0001-grid-replaces-layout-swiss-grid.md +30 -0
- package/docs/adr/0002-content-config-is-app-owned.md +27 -0
- package/docs/adr/0003-modular-step-system-replaces-fluid-clamps.md +58 -0
- package/layers/core/app/assets/css/tokens/spacing-fluid.css +24 -19
- package/layers/core/app/components/Element.test.ts +2 -2
- package/layers/core/app/components/Element.vue +2 -66
- package/layers/core/app/components/ElementTw.vue +2 -66
- package/layers/core/app/composables/useElementStyle.ts +5 -59
- package/layers/core/app/composables/useLayoutAttrs.test.ts +35 -5
- package/layers/core/app/composables/useLayoutAttrs.ts +21 -21
- package/layers/core/app/types/element.ts +41 -18
- package/layers/core/app/utils/resolveElementStyle.test.ts +99 -0
- package/layers/core/app/utils/resolveElementStyle.ts +52 -0
- package/layers/data/metadata/providers/comicvine/server/utils/metadata-comicvine/client.ts +1 -0
- package/layers/data/metadata/providers/comicvine/server/utils/metadata-comicvine/provider.ts +0 -10
- package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/client.ts +12 -11
- package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/provider.ts +0 -7
- package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.ts +12 -23
- package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/provider.ts +0 -10
- package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.ts +4 -7
- package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/provider.ts +0 -6
- package/layers/data/metadata/server/api/metadata/lookup.get.ts +7 -42
- package/layers/data/metadata/server/api/metadata/sync.post.ts +4 -49
- package/layers/data/metadata/server/utils/metadata/provider-fetch.test.ts +27 -0
- package/layers/data/metadata/server/utils/metadata/provider-fetch.ts +14 -0
- package/layers/data/metadata/server/utils/metadata/resolve-record.test.ts +87 -0
- package/layers/data/metadata/server/utils/metadata/resolve-record.ts +60 -0
- package/layers/data/metadata/shared/types/providers.ts +3 -2
- package/layers/design-system/theming/app/types/designSystem.ts +21 -17
- package/layers/design-system/theming/nuxt.config.ts +1 -1
- package/layers/design-system/typography/app/app.config.ts +26 -0
- package/layers/design-system/typography/app/assets/css/typography.css +216 -187
- package/layers/design-system/typography/app/components/Typography/CodeBlock.vue +5 -5
- package/layers/design-system/typography/app/components/Typography/Headline.test.ts +36 -0
- package/layers/design-system/typography/app/components/Typography/Headline.vue +21 -22
- package/layers/design-system/typography/app/components/Typography/QuoteBlock.vue +5 -5
- package/layers/design-system/typography/app/components/Typography/RepeatText.vue +7 -6
- package/layers/design-system/typography/app/components/Typography/index.test.ts +31 -0
- package/layers/design-system/typography/app/components/Typography/index.vue +11 -9
- package/layers/design-system/typography/app/composables/color.test.ts +30 -0
- package/layers/design-system/typography/app/composables/color.ts +9 -2
- package/layers/design-system/typography/app/composables/typography.test.ts +53 -0
- package/layers/design-system/typography/app/composables/typography.ts +28 -43
- package/layers/design-system/typography/app/types/typography.ts +5 -32
- package/layers/design-system/visual/app/composables/tint.test.ts +22 -0
- package/layers/design-system/visual/app/composables/tint.ts +6 -7
- package/layers/render/shader/nuxt.config.ts +1 -1
- package/layers/structure/grid/app/assets/css/grid.css +11 -7
- package/layers/structure/navigation/app/components/Links/Named.vue +1 -1
- package/package.json +5 -4
- package/layers/core/app/composables/useElementGrid.test.ts +0 -33
- package/layers/core/app/composables/useElementGrid.ts +0 -29
- package/layers/core/app/composables/useElementInteraction.test.ts +0 -27
- package/layers/core/app/composables/useElementInteraction.ts +0 -17
- package/layers/core/app/composables/useElementLayout.test.ts +0 -41
- package/layers/core/app/composables/useElementLayout.ts +0 -26
- package/layers/core/app/composables/useElementSizing.test.ts +0 -39
- package/layers/core/app/composables/useElementSizing.ts +0 -25
- package/layers/core/app/composables/useElementSurface.test.ts +0 -33
- package/layers/core/app/composables/useElementSurface.ts +0 -21
- 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
|
+
}
|
package/layers/data/metadata/providers/comicvine/server/utils/metadata-comicvine/provider.ts
CHANGED
|
@@ -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
|
}
|
package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/client.ts
CHANGED
|
@@ -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> = {
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
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
|
}
|
package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/provider.ts
CHANGED
|
@@ -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
|
}
|
package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.ts
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> {
|
package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/provider.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
}
|