kmcom-nuxt-layers 5.0.8 → 6.0.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/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.ts +20 -20
- 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/nuxt.config.ts +1 -1
- package/layers/design-system/typography/app/components/Typography/CodeBlock.vue +1 -1
- package/layers/design-system/typography/app/components/Typography/Headline.vue +1 -1
- package/layers/design-system/typography/app/components/Typography/QuoteBlock.vue +1 -1
- package/layers/design-system/typography/app/components/Typography/index.vue +1 -1
- 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/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/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
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
|
}
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
...(size !== undefined && { size }),
|
|
72
72
|
...(appliedFluidSize.value !== undefined && { fluidSize: appliedFluidSize.value }),
|
|
73
73
|
}))
|
|
74
|
-
const colorClass = useColor(color, 'text')
|
|
74
|
+
const colorClass = useColor(() => color, 'text')
|
|
75
75
|
</script>
|
|
76
76
|
|
|
77
77
|
<template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import { describe, expect, it } from 'vite-plus/test'
|
|
3
|
+
|
|
4
|
+
import { useColor } from './color'
|
|
5
|
+
|
|
6
|
+
describe('useColor', () => {
|
|
7
|
+
it('builds a class from a plain value', () => {
|
|
8
|
+
expect(useColor('primary', 'text').value).toBe('text-primary')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('falls back to default when color is undefined', () => {
|
|
12
|
+
expect(useColor(undefined, 'bg').value).toBe('bg-default')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('tracks a ref', () => {
|
|
16
|
+
const color = ref<'primary' | 'error'>('primary')
|
|
17
|
+
const cls = useColor(color)
|
|
18
|
+
color.value = 'error'
|
|
19
|
+
expect(cls.value).toBe('text-error')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('tracks a getter', () => {
|
|
23
|
+
let color: 'primary' | 'error' = 'primary'
|
|
24
|
+
const state = ref(0)
|
|
25
|
+
const cls = useColor(() => (state.value, color))
|
|
26
|
+
color = 'error'
|
|
27
|
+
state.value++
|
|
28
|
+
expect(cls.value).toBe('text-error')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
|
2
|
+
|
|
1
3
|
import type { ColorUsage, UiColors } from '../types/colors'
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
// Takes a getter (`() => color`) when `color` is a destructured prop — passing the bare
|
|
6
|
+
// value reads it once at setup and the class never updates.
|
|
7
|
+
export function useColor(
|
|
8
|
+
color?: MaybeRefOrGetter<UiColors | undefined>,
|
|
9
|
+
type: ColorUsage = 'text'
|
|
10
|
+
) {
|
|
11
|
+
return computed(() => `${type}-${toValue(color) ?? 'default'}`)
|
|
5
12
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import { useTint } from './tint'
|
|
4
|
+
|
|
5
|
+
describe('useTint', () => {
|
|
6
|
+
it('resolves a slot to its ui color token with the level opacity', () => {
|
|
7
|
+
expect(useTint({ color: 'primary', level: 'light' }).style.value).toEqual({
|
|
8
|
+
backgroundColor: 'var(--ui-color-primary-500)',
|
|
9
|
+
opacity: 0.1,
|
|
10
|
+
})
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('honours shade and the white/black literals', () => {
|
|
14
|
+
expect(
|
|
15
|
+
useTint({ color: 'error', shade: 200, level: 'subtle' }).style.value.backgroundColor
|
|
16
|
+
).toBe('var(--ui-color-error-200)')
|
|
17
|
+
expect(useTint({ color: 'white', level: 'heavy' }).style.value).toEqual({
|
|
18
|
+
backgroundColor: '#ffffff',
|
|
19
|
+
opacity: 0.5,
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
import { toValue, type ComputedRef, type CSSProperties, type MaybeRefOrGetter } from 'vue'
|
|
1
|
+
import { computed, toValue, type ComputedRef, type CSSProperties, type MaybeRefOrGetter } from 'vue'
|
|
2
2
|
|
|
3
3
|
import { TINT_LEVEL_OPACITY, type TintConfig } from '../types/tint'
|
|
4
|
+
import { resolveUiColorToken } from '../utils/colorTokens'
|
|
4
5
|
|
|
5
6
|
export function useTint(config: MaybeRefOrGetter<TintConfig>): {
|
|
6
7
|
style: ComputedRef<CSSProperties>
|
|
7
8
|
} {
|
|
8
9
|
const style = computed((): CSSProperties => {
|
|
9
10
|
const { color, shade = 500, level } = toValue(config)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const opacity = TINT_LEVEL_OPACITY[level] / 100
|
|
15
|
-
return { backgroundColor, opacity }
|
|
11
|
+
return {
|
|
12
|
+
backgroundColor: resolveUiColorToken({ color, shade }),
|
|
13
|
+
opacity: TINT_LEVEL_OPACITY[level] / 100,
|
|
14
|
+
}
|
|
16
15
|
})
|
|
17
16
|
|
|
18
17
|
return { style }
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
variant?: string
|
|
18
18
|
color?: UiColors
|
|
19
19
|
}>()
|
|
20
|
-
const colorClass = useColor(color, 'text')
|
|
20
|
+
const colorClass = useColor(() => color, 'text')
|
|
21
21
|
// `name` is a plain string here, not one of the app's typed route names
|
|
22
22
|
const to = computed(
|
|
23
23
|
() => ({ name, params: params ?? {}, query: query ?? {} }) as unknown as LinkProps['to']
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kmcom-nuxt-layers",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "6.0.0",
|
|
5
5
|
"description": "Composable Nuxt 4 layers for building scalable Vue applications",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./layers/core": "./layers/core/nuxt.config.ts",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"./layers/navigation": "./layers/structure/navigation/nuxt.config.ts",
|
|
12
12
|
"./layers/visual": "./layers/design-system/visual/nuxt.config.ts",
|
|
13
13
|
"./layers/layout": "./layers/structure/layout/nuxt.config.ts",
|
|
14
|
+
"./layers/grid": "./layers/structure/grid/nuxt.config.ts",
|
|
14
15
|
"./layers/scroll": "./layers/motion/scroll/nuxt.config.ts",
|
|
15
16
|
"./layers/animations": "./layers/motion/animations/nuxt.config.ts",
|
|
16
17
|
"./layers/transitions": "./layers/motion/transitions/nuxt.config.ts",
|
|
@@ -167,7 +168,7 @@
|
|
|
167
168
|
"better-sqlite3": "^12.10.0",
|
|
168
169
|
"@perplex-digital/stylelint-config": "^17.4.0",
|
|
169
170
|
"@pinia/nuxt": "^0.11.3",
|
|
170
|
-
"@playwright/test": "1.
|
|
171
|
+
"@playwright/test": "1.64.1",
|
|
171
172
|
"@types/node": "^25.9.1",
|
|
172
173
|
"@types/three": "^0.184.1",
|
|
173
174
|
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
|
@@ -207,8 +208,8 @@
|
|
|
207
208
|
"vite-plugin-checker": "^0.14.5",
|
|
208
209
|
"vitest": "5.0.5",
|
|
209
210
|
"vue": "3.5.35",
|
|
210
|
-
"vue-tsc": "3.3.
|
|
211
|
-
"zod": "^4.
|
|
211
|
+
"vue-tsc": "3.3.13",
|
|
212
|
+
"zod": "^4.3.6",
|
|
212
213
|
"zod-to-json-schema": "^3.25.2",
|
|
213
214
|
"vite-plus": "1.3.0"
|
|
214
215
|
},
|