kmcom-nuxt-layers 2.3.2 → 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.
- package/README.md +2 -0
- package/docs/ARCHITECTURE.md +4 -0
- package/layers/auth/app/middleware/auth.ts +4 -0
- package/layers/auth/nuxt.config.ts +31 -0
- package/layers/auth/package.json +15 -0
- package/layers/auth/server/routes/auth/github.get.ts +13 -0
- package/layers/auth/tsconfig.json +7 -0
- package/layers/baseline/app/components/BaselineStatus.vue +17 -0
- package/layers/baseline/app/plugins/baseline-status.client.ts +3 -0
- package/layers/baseline/nuxt.config.ts +24 -0
- package/layers/baseline/package.json +14 -0
- package/layers/baseline/tsconfig.json +7 -0
- package/layers/database/nuxt.config.ts +30 -0
- package/layers/database/package.json +16 -0
- package/layers/database/server/utils/drizzle.ts +5 -0
- package/layers/database/server/utils/sql.ts +7 -0
- package/layers/database/tsconfig.json +7 -0
- package/layers/metadata/app/composables/useMetadataItem.ts +11 -0
- package/layers/metadata/app/composables/useMetadataSearch.ts +25 -0
- package/layers/metadata/nuxt.config.ts +27 -0
- package/layers/metadata/package.json +12 -0
- package/layers/metadata/server/api/metadata/lookup.get.ts +40 -0
- package/layers/metadata/server/api/metadata/providers.get.ts +7 -0
- package/layers/metadata/server/api/metadata/search.get.ts +17 -0
- package/layers/metadata/server/api/metadata/sync.post.ts +40 -0
- package/layers/metadata/server/utils/metadata/cache.ts +36 -0
- package/layers/metadata/server/utils/metadata/errors.ts +24 -0
- package/layers/metadata/server/utils/metadata/provider-registry.ts +15 -0
- package/layers/metadata/server/utils/metadata/search.ts +42 -0
- package/layers/metadata/tsconfig.json +7 -0
- package/layers/metadata-comicvine/nuxt.config.ts +36 -0
- package/layers/metadata-comicvine/package.json +12 -0
- package/layers/metadata-comicvine/server/plugins/comicvine-register.ts +3 -0
- package/layers/metadata-comicvine/server/utils/metadata-comicvine/client.ts +52 -0
- package/layers/metadata-comicvine/server/utils/metadata-comicvine/normalise.ts +55 -0
- package/layers/metadata-comicvine/server/utils/metadata-comicvine/provider.ts +39 -0
- package/layers/metadata-comicvine/server/utils/metadata-comicvine/types.ts +36 -0
- package/layers/metadata-comicvine/tsconfig.json +7 -0
- package/layers/metadata-google-books/nuxt.config.ts +32 -0
- package/layers/metadata-google-books/package.json +12 -0
- package/layers/metadata-google-books/server/plugins/google-books-register.ts +3 -0
- package/layers/metadata-google-books/server/utils/metadata-google-books/client.ts +23 -0
- package/layers/metadata-google-books/server/utils/metadata-google-books/normalise.ts +39 -0
- package/layers/metadata-google-books/server/utils/metadata-google-books/provider.ts +21 -0
- package/layers/metadata-google-books/server/utils/metadata-google-books/types.ts +25 -0
- package/layers/metadata-google-books/tsconfig.json +7 -0
- package/layers/metadata-openlibrary/nuxt.config.ts +18 -0
- package/layers/metadata-openlibrary/package.json +12 -0
- package/layers/metadata-openlibrary/server/plugins/openlibrary-register.ts +3 -0
- package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/client.ts +30 -0
- package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/normalise.ts +81 -0
- package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/provider.ts +25 -0
- package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/types.ts +48 -0
- package/layers/metadata-openlibrary/tsconfig.json +7 -0
- package/package.json +12 -3
- package/types/auth.ts +7 -0
- package/types/index.ts +2 -0
package/README.md
CHANGED
|
@@ -29,6 +29,8 @@ export default defineNuxtConfig({
|
|
|
29
29
|
| **motion** | Animations — GSAP, Locomotive Scroll |
|
|
30
30
|
| **shader** | 3D / WebGPU — Three.js, TresJS |
|
|
31
31
|
| **forms** | Validation — Zod-based form system |
|
|
32
|
+
| **database** | Storage — Drizzle ORM + Neon Postgres |
|
|
33
|
+
| **auth** | Authentication — nuxt-auth-utils + GitHub OAuth |
|
|
32
34
|
| **theme** | Theming — color mode, accent colors |
|
|
33
35
|
| **content** | CMS — @nuxt/content, Nuxt Studio |
|
|
34
36
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -15,6 +15,8 @@ nuxt-layers/
|
|
|
15
15
|
│ ├── motion/ # GSAP + Locomotive Scroll animations
|
|
16
16
|
│ ├── shader/ # Three.js / TresJS WebGPU shaders
|
|
17
17
|
│ ├── forms/ # Zod-based form validation
|
|
18
|
+
│ ├── database/ # Drizzle + Neon Postgres connection
|
|
19
|
+
│ ├── auth/ # nuxt-auth-utils + GitHub OAuth
|
|
18
20
|
│ ├── theme/ # Color mode, accent colors, accessibility
|
|
19
21
|
│ └── content/ # @nuxt/content integration, Nuxt Studio
|
|
20
22
|
├── packages/ # Shared utilities (placeholder)
|
|
@@ -192,5 +194,7 @@ Some layers (ui, content) support standalone mode via environment variables (`UI
|
|
|
192
194
|
| **motion** | Animation layer. GSAP with ScrollTrigger, Locomotive Scroll (Lenis) smooth scrolling, motion composables. Transpiles GSAP for SSR. |
|
|
193
195
|
| **shader** | WebGPU/WebGL layer. Three.js + TresJS integration, TSL shader support, post-processing. Includes custom shader utilities and auto-imported shader modules. |
|
|
194
196
|
| **forms** | Form validation layer. Zod schema-based validation, form composables, typed form components. |
|
|
197
|
+
| **database** | Database infrastructure. Drizzle ORM + Neon Postgres via HTTP driver. Exposes `useSql()` and `useDrizzle(schema)` server utilities. App-owned schema and migrations; env: `NUXT_DATABASE_LAYER_URL`. |
|
|
198
|
+
| **auth** | Authentication layer. nuxt-auth-utils + GitHub OAuth handler, sealed-cookie sessions. Opt-in `auth` route middleware; no user table (app's responsibility). Env: `NUXT_SESSION_PASSWORD`, `NUXT_OAUTH_GITHUB_CLIENT_ID/SECRET`. |
|
|
195
199
|
| **theme** | Theming layer. Color mode switching, accent color palette, theme plugin, CSS custom properties for dynamic theming. |
|
|
196
200
|
| **content** | Content management layer. @nuxt/content integration, better-sqlite3 for local content DB, optional Nuxt Studio support. |
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export default defineNuxtConfig({
|
|
2
|
+
$meta: {
|
|
3
|
+
name: 'auth',
|
|
4
|
+
},
|
|
5
|
+
|
|
6
|
+
extends: ['../core'],
|
|
7
|
+
|
|
8
|
+
modules: ['nuxt-auth-utils'],
|
|
9
|
+
|
|
10
|
+
alias: {
|
|
11
|
+
'#layers/auth': import.meta.dirname,
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
compatibilityDate: '2026-06-28',
|
|
15
|
+
|
|
16
|
+
runtimeConfig: {
|
|
17
|
+
// nuxt-auth-utils reads these env vars automatically:
|
|
18
|
+
session: { password: '' }, // env: NUXT_SESSION_PASSWORD
|
|
19
|
+
oauth: {
|
|
20
|
+
github: {
|
|
21
|
+
clientId: '', // env: NUXT_OAUTH_GITHUB_CLIENT_ID
|
|
22
|
+
clientSecret: '', // env: NUXT_OAUTH_GITHUB_CLIENT_SECRET
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
typescript: {
|
|
28
|
+
typeCheck: false,
|
|
29
|
+
strict: true,
|
|
30
|
+
},
|
|
31
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kmcom-layer-auth",
|
|
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,auth pnpm -F playground dev",
|
|
9
|
+
"typecheck": "vue-tsc --noEmit -p ../../tsconfig.typecheck.json",
|
|
10
|
+
"lint": "eslint ."
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"nuxt-auth-utils": "catalog:"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export default defineOAuthGitHubEventHandler({
|
|
2
|
+
async onSuccess(event, { user }) {
|
|
3
|
+
await setUserSession(event, {
|
|
4
|
+
user: {
|
|
5
|
+
provider: 'github',
|
|
6
|
+
providerId: user.id,
|
|
7
|
+
username: user.login,
|
|
8
|
+
avatarUrl: user.avatar_url,
|
|
9
|
+
},
|
|
10
|
+
})
|
|
11
|
+
return sendRedirect(event, '/')
|
|
12
|
+
},
|
|
13
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { defineComponent, h } from 'vue'
|
|
3
|
+
|
|
4
|
+
export default defineComponent({
|
|
5
|
+
name: 'BaselineStatus',
|
|
6
|
+
props: {
|
|
7
|
+
featureId: {
|
|
8
|
+
type: String,
|
|
9
|
+
required: true,
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
setup(props) {
|
|
13
|
+
if (import.meta.server) return () => null
|
|
14
|
+
return () => h('baseline-status', { featureId: props.featureId })
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
</script>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export default defineNuxtConfig({
|
|
2
|
+
$meta: {
|
|
3
|
+
name: 'baseline',
|
|
4
|
+
},
|
|
5
|
+
|
|
6
|
+
extends: ['../core'],
|
|
7
|
+
|
|
8
|
+
alias: {
|
|
9
|
+
'#layers/baseline': import.meta.dirname,
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
compatibilityDate: '2026-06-06',
|
|
13
|
+
|
|
14
|
+
vue: {
|
|
15
|
+
compilerOptions: {
|
|
16
|
+
isCustomElement: (tag) => tag === 'baseline-status' || tag === 'baseline-icon',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
typescript: {
|
|
21
|
+
typeCheck: false,
|
|
22
|
+
strict: true,
|
|
23
|
+
},
|
|
24
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kmcom-layer-baseline",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./nuxt.config.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev:playground": "PLAYGROUND_LAYERS=core,baseline pnpm -F playground dev",
|
|
8
|
+
"typecheck": "vue-tsc --noEmit -p ../../tsconfig.typecheck.json",
|
|
9
|
+
"lint": "eslint ."
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"baseline-status": "catalog:"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export default defineNuxtConfig({
|
|
2
|
+
$meta: {
|
|
3
|
+
name: 'database',
|
|
4
|
+
},
|
|
5
|
+
|
|
6
|
+
extends: ['../core'],
|
|
7
|
+
|
|
8
|
+
alias: {
|
|
9
|
+
'#layers/database': import.meta.dirname,
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
compatibilityDate: '2026-06-28',
|
|
13
|
+
|
|
14
|
+
runtimeConfig: {
|
|
15
|
+
databaseLayer: {
|
|
16
|
+
url: '', // env: NUXT_DATABASE_LAYER_URL (Neon connection string)
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
typescript: {
|
|
21
|
+
typeCheck: false,
|
|
22
|
+
strict: true,
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
declare module '@nuxt/schema' {
|
|
27
|
+
interface RuntimeConfig {
|
|
28
|
+
databaseLayer?: { url: string }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kmcom-layer-database",
|
|
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,database pnpm -F playground dev",
|
|
9
|
+
"typecheck": "vue-tsc --noEmit -p ../../tsconfig.typecheck.json",
|
|
10
|
+
"lint": "eslint ."
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@neondatabase/serverless": "catalog:",
|
|
14
|
+
"drizzle-orm": "catalog:"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MetadataProviderId, MetadataRecord } from '#layers/metadata/shared/types'
|
|
2
|
+
|
|
3
|
+
export function useMetadataItem(provider: MetadataProviderId, providerId: string, resourceType?: string) {
|
|
4
|
+
return useAsyncData(
|
|
5
|
+
`metadata-item:${provider}:${providerId}`,
|
|
6
|
+
() =>
|
|
7
|
+
$fetch('/api/metadata/lookup', {
|
|
8
|
+
query: { provider, providerId, resourceType },
|
|
9
|
+
}) as Promise<MetadataRecord>,
|
|
10
|
+
)
|
|
11
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MetadataMediaType, MetadataProviderId, MetadataSearchResult } from '#layers/metadata/shared/types'
|
|
2
|
+
|
|
3
|
+
type UseMetadataSearchOptions = {
|
|
4
|
+
mediaType?: MetadataMediaType
|
|
5
|
+
providers?: MetadataProviderId[]
|
|
6
|
+
limit?: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useMetadataSearch(query: MaybeRef<string>, options: UseMetadataSearchOptions = {}) {
|
|
10
|
+
const q = computed(() => toValue(query).trim())
|
|
11
|
+
|
|
12
|
+
return useAsyncData(
|
|
13
|
+
() => `metadata-search:${q.value}`,
|
|
14
|
+
() =>
|
|
15
|
+
$fetch('/api/metadata/search', {
|
|
16
|
+
query: {
|
|
17
|
+
q: q.value,
|
|
18
|
+
mediaType: options.mediaType,
|
|
19
|
+
providers: options.providers?.join(','),
|
|
20
|
+
limit: options.limit,
|
|
21
|
+
},
|
|
22
|
+
}) as Promise<MetadataSearchResult[]>,
|
|
23
|
+
{ watch: [q], immediate: false },
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export default defineNuxtConfig({
|
|
2
|
+
$meta: {
|
|
3
|
+
name: 'metadata',
|
|
4
|
+
},
|
|
5
|
+
|
|
6
|
+
extends: ['../core'],
|
|
7
|
+
|
|
8
|
+
alias: {
|
|
9
|
+
'#layers/metadata': import.meta.dirname,
|
|
10
|
+
'#layers/metadata/shared': `${import.meta.dirname}/shared`,
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
compatibilityDate: '2026-06-30',
|
|
14
|
+
|
|
15
|
+
nitro: {
|
|
16
|
+
storage: {
|
|
17
|
+
metadata: {
|
|
18
|
+
driver: 'memory',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
typescript: {
|
|
24
|
+
typeCheck: false,
|
|
25
|
+
strict: true,
|
|
26
|
+
},
|
|
27
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kmcom-layer-metadata",
|
|
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 pnpm -F playground dev",
|
|
9
|
+
"typecheck": "vue-tsc --noEmit -p ../../tsconfig.typecheck.json",
|
|
10
|
+
"lint": "eslint ."
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { MetadataProviderId } from '#layers/metadata/shared/types'
|
|
2
|
+
|
|
3
|
+
// fallow-ignore-next-line complexity
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const query = getQuery(event)
|
|
6
|
+
|
|
7
|
+
const providerId = String(query.providerId ?? '').trim()
|
|
8
|
+
const providerName = String(query.provider ?? '').trim() as MetadataProviderId
|
|
9
|
+
const resourceType = query.resourceType ? String(query.resourceType) : undefined
|
|
10
|
+
|
|
11
|
+
if (!providerName || !providerId) {
|
|
12
|
+
throw createError({ statusCode: 400, message: 'provider and providerId are required' })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const provider = getProvider(providerName)
|
|
16
|
+
if (!provider) throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
|
|
17
|
+
|
|
18
|
+
const cacheKey = makeCacheKey('metadata', providerName, resourceType ?? 'item', providerId)
|
|
19
|
+
const cached = await getCacheRecord(cacheKey)
|
|
20
|
+
if (cached) return cached.normalised
|
|
21
|
+
|
|
22
|
+
const result = await provider.lookup({ provider: providerName, providerId, resourceType })
|
|
23
|
+
if (!result) throw createError({ statusCode: 404, message: 'Not found' })
|
|
24
|
+
|
|
25
|
+
const now = new Date().toISOString()
|
|
26
|
+
await setCacheRecord(cacheKey, {
|
|
27
|
+
id: result.id,
|
|
28
|
+
provider: providerName,
|
|
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 result
|
|
40
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MetadataMediaType, MetadataProviderId } from '#layers/metadata/shared/types'
|
|
2
|
+
|
|
3
|
+
// fallow-ignore-next-line complexity
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const query = getQuery(event)
|
|
6
|
+
|
|
7
|
+
const q = String(query.q ?? '').trim()
|
|
8
|
+
if (!q) throw createError({ statusCode: 400, message: 'q is required' })
|
|
9
|
+
|
|
10
|
+
const mediaType = query.mediaType as MetadataMediaType | undefined
|
|
11
|
+
const providers = query.providers
|
|
12
|
+
? String(query.providers).split(',').filter(Boolean) as MetadataProviderId[]
|
|
13
|
+
: undefined
|
|
14
|
+
const limit = query.limit ? Number(query.limit) : 10
|
|
15
|
+
|
|
16
|
+
return searchMetadata({ query: q, mediaType, providers, limit })
|
|
17
|
+
})
|
|
@@ -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,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
|
+
}
|