kmcom-nuxt-layers 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +2 -0
  2. package/docs/ARCHITECTURE.md +4 -0
  3. package/layers/auth/app/middleware/auth.ts +4 -0
  4. package/layers/auth/nuxt.config.ts +31 -0
  5. package/layers/auth/package.json +15 -0
  6. package/layers/auth/server/routes/auth/github.get.ts +13 -0
  7. package/layers/auth/tsconfig.json +7 -0
  8. package/layers/baseline/app/components/BaselineStatus.vue +17 -0
  9. package/layers/baseline/app/plugins/baseline-status.client.ts +3 -0
  10. package/layers/baseline/nuxt.config.ts +24 -0
  11. package/layers/baseline/package.json +14 -0
  12. package/layers/baseline/tsconfig.json +7 -0
  13. package/layers/content/app/composables/createPortfolioComposables.ts +2 -1
  14. package/layers/content/app/composables/useBlogPosts.ts +2 -1
  15. package/layers/content/app/composables/useGalleryItems.ts +14 -8
  16. package/layers/content/app/composables/usePortfolioItems.ts +18 -10
  17. package/layers/database/nuxt.config.ts +30 -0
  18. package/layers/database/package.json +16 -0
  19. package/layers/database/server/utils/drizzle.ts +5 -0
  20. package/layers/database/server/utils/sql.ts +7 -0
  21. package/layers/database/tsconfig.json +7 -0
  22. package/layers/feeds/server/utils/content-adapter.ts +6 -0
  23. package/layers/forms/app/components/Form/Contact.vue +4 -13
  24. package/layers/forms/app/utils/contact-schema.ts +9 -0
  25. package/layers/forms/server/api/contact.post.ts +23 -6
  26. package/layers/forms/server/api/forms/status.get.ts +1 -3
  27. package/layers/layout/app/utils/gridPlacementStyle.ts +10 -9
  28. package/layers/metadata/app/composables/useMetadataItem.ts +11 -0
  29. package/layers/metadata/app/composables/useMetadataSearch.ts +25 -0
  30. package/layers/metadata/nuxt.config.ts +27 -0
  31. package/layers/metadata/package.json +12 -0
  32. package/layers/metadata/server/api/metadata/lookup.get.ts +40 -0
  33. package/layers/metadata/server/api/metadata/providers.get.ts +7 -0
  34. package/layers/metadata/server/api/metadata/search.get.ts +17 -0
  35. package/layers/metadata/server/api/metadata/sync.post.ts +40 -0
  36. package/layers/metadata/server/utils/metadata/cache.ts +36 -0
  37. package/layers/metadata/server/utils/metadata/errors.ts +24 -0
  38. package/layers/metadata/server/utils/metadata/provider-registry.ts +15 -0
  39. package/layers/metadata/server/utils/metadata/search.ts +42 -0
  40. package/layers/metadata/tsconfig.json +7 -0
  41. package/layers/metadata-comicvine/nuxt.config.ts +36 -0
  42. package/layers/metadata-comicvine/package.json +12 -0
  43. package/layers/metadata-comicvine/server/plugins/comicvine-register.ts +3 -0
  44. package/layers/metadata-comicvine/server/utils/metadata-comicvine/client.ts +52 -0
  45. package/layers/metadata-comicvine/server/utils/metadata-comicvine/normalise.ts +55 -0
  46. package/layers/metadata-comicvine/server/utils/metadata-comicvine/provider.ts +39 -0
  47. package/layers/metadata-comicvine/server/utils/metadata-comicvine/types.ts +36 -0
  48. package/layers/metadata-comicvine/tsconfig.json +7 -0
  49. package/layers/metadata-google-books/nuxt.config.ts +32 -0
  50. package/layers/metadata-google-books/package.json +12 -0
  51. package/layers/metadata-google-books/server/plugins/google-books-register.ts +3 -0
  52. package/layers/metadata-google-books/server/utils/metadata-google-books/client.ts +23 -0
  53. package/layers/metadata-google-books/server/utils/metadata-google-books/normalise.ts +39 -0
  54. package/layers/metadata-google-books/server/utils/metadata-google-books/provider.ts +21 -0
  55. package/layers/metadata-google-books/server/utils/metadata-google-books/types.ts +25 -0
  56. package/layers/metadata-google-books/tsconfig.json +7 -0
  57. package/layers/metadata-openlibrary/nuxt.config.ts +18 -0
  58. package/layers/metadata-openlibrary/package.json +12 -0
  59. package/layers/metadata-openlibrary/server/plugins/openlibrary-register.ts +3 -0
  60. package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/client.ts +30 -0
  61. package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/normalise.ts +81 -0
  62. package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/provider.ts +25 -0
  63. package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/types.ts +48 -0
  64. package/layers/metadata-openlibrary/tsconfig.json +7 -0
  65. package/layers/routing/server/api/feature-flags.get.ts +6 -0
  66. package/layers/scroll/app/plugins/locomotive-scroll.client.ts +6 -1
  67. package/layers/starter/app/app.config.ts +10 -0
  68. package/layers/starter/app/components/StarterHome.vue +34 -25
  69. package/layers/theme/app/components/ThemePicker/Menu.vue +8 -2
  70. package/layers/theme/nuxt.config.ts +1 -1
  71. package/layers/theme/server/plugins/theme-fouc.ts +2 -57
  72. package/layers/theme/server/utils/fouc-config.ts +85 -0
  73. package/package.json +12 -3
  74. package/types/auth.ts +7 -0
  75. package/types/index.ts +2 -0
  76. package/layers/content/app/composables/useCollectionItems.ts +0 -28
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
 
@@ -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,4 @@
1
+ export default defineNuxtRouteMiddleware(() => {
2
+ const { loggedIn } = useUserSession()
3
+ if (!loggedIn.value) return navigateTo('/auth/github')
4
+ })
@@ -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,7 @@
1
+ {
2
+ "extends": "../../apps/playground/tsconfig.json",
3
+ "compilerOptions": {
4
+ "strict": true
5
+ },
6
+ "exclude": ["node_modules"]
7
+ }
@@ -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,3 @@
1
+ import 'baseline-status'
2
+
3
+ export default defineNuxtPlugin(() => {})
@@ -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,7 @@
1
+ {
2
+ "extends": "../../apps/playground/tsconfig.json",
3
+ "compilerOptions": {
4
+ "strict": true
5
+ },
6
+ "exclude": ["node_modules"]
7
+ }
@@ -20,7 +20,8 @@ export function createPortfolioComposables(collectionName: keyof Collections) {
20
20
  function useItems(options: PortfolioQueryOptions = {}) {
21
21
  const { featured, tags, limit } = options
22
22
 
23
- return useContentData(`${collectionName}-items`, async () => {
23
+ const key = `${collectionName}-items:${featured ?? ''}:${(tags ?? []).slice().sort().join(',')}:${limit ?? ''}`
24
+ return useContentData(key, async () => {
24
25
  let items: CollectionItem[] = (await queryCollection(collectionName).all()).sort(
25
26
  (a, b) => itemYear(b) - itemYear(a)
26
27
  )
@@ -3,7 +3,8 @@ import type { BlogQueryOptions } from '../types/content'
3
3
  export function useBlogPosts(options: BlogQueryOptions = {}) {
4
4
  const { excludeDrafts = true, tags, limit } = options
5
5
 
6
- return useContentData('blog-posts', async () => {
6
+ const key = `blog-posts:${excludeDrafts}:${(tags ?? []).slice().sort().join(',')}:${limit ?? ''}`
7
+ return useContentData(key, async () => {
7
8
  let posts = (await queryCollection('blog').all()).sort((a, b) =>
8
9
  (b.date ?? '').localeCompare(a.date ?? '')
9
10
  )
@@ -1,15 +1,21 @@
1
1
  import type { GalleryQueryOptions } from '../types/content'
2
- import { useCollectionItems } from './useCollectionItems'
3
2
 
4
3
  export function useGalleryItems(options: GalleryQueryOptions = {}) {
5
4
  const { tags, limit } = options
5
+ const key = `gallery-items:${(tags ?? []).slice().sort().join(',')}:${limit ?? ''}`
6
6
 
7
- return useCollectionItems({
8
- key: 'gallery-items',
9
- collection: 'gallery',
10
- sort: (a, b) => (b.date ?? '').localeCompare(a.date ?? ''),
11
- options: { limit },
12
- filter: (item) =>
13
- !tags?.length || Boolean(item.tags?.some((tag: string) => tags.includes(tag))),
7
+ return useContentData(key, async () => {
8
+ let items = (await queryCollection('gallery').all())
9
+ .sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''))
10
+
11
+ if (tags?.length) {
12
+ items = items.filter((item) => Boolean(item.tags?.some((tag: string) => tags.includes(tag))))
13
+ }
14
+
15
+ if (limit !== undefined) {
16
+ items = items.slice(0, limit)
17
+ }
18
+
19
+ return items
14
20
  })
15
21
  }
@@ -1,17 +1,25 @@
1
1
  import type { PortfolioQueryOptions } from '../types/content'
2
- import { useCollectionItems } from './useCollectionItems'
3
2
 
4
3
  export function usePortfolioItems(options: PortfolioQueryOptions = {}) {
5
4
  const { featured, tags, limit } = options
5
+ const key = `portfolio-items:${featured ?? ''}:${(tags ?? []).slice().sort().join(',')}:${limit ?? ''}`
6
6
 
7
- return useCollectionItems({
8
- key: 'portfolio-items',
9
- collection: 'portfolio',
10
- sort: (a, b) => (b.year ?? 0) - (a.year ?? 0),
11
- options: { limit },
12
- // fallow-ignore-next-line complexity
13
- filter: (item) =>
14
- (featured === undefined || item.featured === featured) &&
15
- (!tags?.length || Boolean(item.tags?.some((tag: string) => tags.includes(tag)))),
7
+ return useContentData(key, async () => {
8
+ let items = (await queryCollection('portfolio').all())
9
+ .sort((a, b) => (b.year ?? 0) - (a.year ?? 0))
10
+
11
+ if (featured !== undefined) {
12
+ items = items.filter((item) => item.featured === featured)
13
+ }
14
+
15
+ if (tags?.length) {
16
+ items = items.filter((item) => Boolean(item.tags?.some((tag: string) => tags.includes(tag))))
17
+ }
18
+
19
+ if (limit) {
20
+ items = items.slice(0, limit)
21
+ }
22
+
23
+ return items
16
24
  })
17
25
  }
@@ -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,5 @@
1
+ import { drizzle } from 'drizzle-orm/neon-http'
2
+
3
+ export function useDrizzle<TSchema extends Record<string, unknown>>(schema: TSchema) {
4
+ return drizzle(useSql(), { schema })
5
+ }
@@ -0,0 +1,7 @@
1
+ import { neon } from '@neondatabase/serverless'
2
+
3
+ export function useSql() {
4
+ const { databaseLayer } = useRuntimeConfig()
5
+ if (!databaseLayer?.url) throw createError('Database URL not configured')
6
+ return neon(databaseLayer.url)
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../apps/playground/tsconfig.json",
3
+ "compilerOptions": {
4
+ "strict": true
5
+ },
6
+ "exclude": ["node_modules"]
7
+ }
@@ -26,6 +26,8 @@ type FeedCollectionQuery = {
26
26
  all: () => Promise<FeedSourceItem[]>
27
27
  }
28
28
 
29
+ const COLLECTION_NAME_RE = /^[a-z][a-z0-9_-]*$/i
30
+
29
31
  const getFeedCollection = queryCollection as unknown as (
30
32
  event: H3Event,
31
33
  collection: string
@@ -36,6 +38,10 @@ export async function getContentFeedItems(
36
38
  collection: string = 'blog',
37
39
  limit: number = 30
38
40
  ): Promise<FeedItem[]> {
41
+ if (!COLLECTION_NAME_RE.test(collection)) {
42
+ throw createError({ statusCode: 400, statusMessage: 'Invalid collection name' })
43
+ }
44
+
39
45
  // queryCollection is keyed by literal collection names, but feed routes accept
40
46
  // a runtime collection string; keep the unsafe bridge local here.
41
47
  const raw = await getFeedCollection(event, collection).all()
@@ -1,24 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  import type { FormSubmitEvent } from '@nuxt/ui'
3
- import { z } from 'zod'
4
3
 
5
- import { fieldConfigs } from '../../config/fields'
4
+ import { contactSchema, type ContactData } from '../../utils/contact-schema'
6
5
 
7
6
  const emit = defineEmits<{
8
- submit: [data: FormState]
7
+ submit: [data: ContactData]
9
8
  }>()
10
9
 
11
- const schema = z.object({
12
- name: fieldConfigs.name.validation.pipe(
13
- z.string().min(3, 'Name must be at least 3 characters')
14
- ),
15
- email: fieldConfigs.email.validation,
16
- message: fieldConfigs.textarea.validation.pipe(
17
- z.string().min(8, 'Message must be at least 8 characters')
18
- ),
19
- })
10
+ const schema = contactSchema
20
11
 
21
- type FormState = z.infer<typeof schema>
12
+ type FormState = ContactData
22
13
 
23
14
  const state = reactive({ name: '', email: '', message: '' })
24
15
 
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod'
2
+
3
+ export const contactSchema = z.object({
4
+ name: z.string().min(3, 'Name must be at least 3 characters'),
5
+ email: z.string().email('Please enter a valid email'),
6
+ message: z.string().min(8, 'Message must be at least 8 characters'),
7
+ })
8
+
9
+ export type ContactData = z.infer<typeof contactSchema>
@@ -1,14 +1,31 @@
1
1
  import { sendContactEmail } from '#layers/mailer/server/utils/email'
2
2
  import { mailerLayerHooks } from '#layers/mailer/server/utils/hooks'
3
- import { z } from 'zod'
4
3
 
5
- const contactSchema = z.object({
6
- name: z.string().min(3, 'Name must be at least 3 characters'),
7
- email: z.string().email('Please enter a valid email'),
8
- message: z.string().min(8, 'Message must be at least 8 characters'),
9
- })
4
+ import { contactSchema } from '../../app/utils/contact-schema'
5
+
6
+ type RateLimitRecord = { count: number; resetAt: number }
7
+ const rateLimitStore = new Map<string, RateLimitRecord>()
8
+ const RATE_LIMIT_MAX = 5
9
+ const RATE_LIMIT_WINDOW_MS = 60_000
10
+
11
+ function checkRateLimit(ip: string): boolean {
12
+ const now = Date.now()
13
+ const record = rateLimitStore.get(ip)
14
+ if (!record || now > record.resetAt) {
15
+ rateLimitStore.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
16
+ return true
17
+ }
18
+ if (record.count >= RATE_LIMIT_MAX) return false
19
+ record.count++
20
+ return true
21
+ }
10
22
 
11
23
  export default defineEventHandler(async (event) => {
24
+ const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
25
+ if (!checkRateLimit(ip)) {
26
+ throw createError({ statusCode: 429, statusMessage: 'Too many requests. Please try again later.' })
27
+ }
28
+
12
29
  const body = await readBody(event)
13
30
  const result = contactSchema.safeParse(body)
14
31
 
@@ -1,11 +1,9 @@
1
1
  import { useMailerConfig } from '#layers/mailer/server/utils/config'
2
2
 
3
3
  export default defineEventHandler(() => {
4
- const { resendApiKey, emailFrom, emailTo } = useMailerConfig()
4
+ const { resendApiKey } = useMailerConfig()
5
5
 
6
6
  return {
7
7
  configured: Boolean(resendApiKey),
8
- emailFrom: emailFrom || null,
9
- emailTo: emailTo || null,
10
8
  }
11
9
  })
@@ -164,16 +164,17 @@ export function buildGridPlacementStyle(input: GridPlacementInput): PlacementSty
164
164
  style['--_re'] = String(rowSpan)
165
165
  Object.assign(style, resolveResponsivePlacementVars(input.rowStart, 'rs'))
166
166
  Object.assign(style, resolveResponsivePlacementVars(input.rowSpan, 're'))
167
- } else {
168
- style['--_cs'] = String(colStart ?? 'auto')
169
- style['--_ce'] = String(colSpan)
170
- style['--_rs'] = String(rowStart ?? 'auto')
171
- style['--_re'] = String(rowSpan)
172
- Object.assign(style, resolveResponsivePlacementVars(input.colStart, 'cs'))
173
- Object.assign(style, resolveResponsivePlacementVars(input.colSpan, 'ce'))
174
- Object.assign(style, resolveResponsivePlacementVars(input.rowStart, 'rs'))
175
- Object.assign(style, resolveResponsivePlacementVars(input.rowSpan, 're'))
167
+ return style
176
168
  }
169
+
170
+ style['--_cs'] = String(colStart ?? 'auto')
171
+ style['--_ce'] = String(colSpan)
172
+ style['--_rs'] = String(rowStart ?? 'auto')
173
+ style['--_re'] = String(rowSpan)
174
+ Object.assign(style, resolveResponsivePlacementVars(input.colStart, 'cs'))
175
+ Object.assign(style, resolveResponsivePlacementVars(input.colSpan, 'ce'))
176
+ Object.assign(style, resolveResponsivePlacementVars(input.rowStart, 'rs'))
177
+ Object.assign(style, resolveResponsivePlacementVars(input.rowSpan, 're'))
177
178
  }
178
179
 
179
180
  Object.assign(style, resolveAlignmentStyles(input.align, input.justify))
@@ -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,7 @@
1
+ export default defineEventHandler(() => {
2
+ return getProviders().map((p) => ({
3
+ id: p.id,
4
+ label: p.label,
5
+ mediaTypes: p.mediaTypes,
6
+ }))
7
+ })
@@ -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
+ })