kmcom-nuxt-layers 2.3.0 → 2.3.2

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.
@@ -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
  }
@@ -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,6 @@
1
+ import type { FeatureValue } from '../../app/types/routing'
2
+
3
+ export default defineEventHandler(() => {
4
+ const config = useAppConfig() as { routingLayer?: { features?: Record<string, FeatureValue> } }
5
+ return config.routingLayer?.features ?? {}
6
+ })
@@ -47,7 +47,12 @@ export default defineNuxtPlugin({
47
47
 
48
48
  if (smoothScroll === true) {
49
49
  nuxtApp.hook('app:mounted', () => nextTick(init))
50
- nuxtApp.hook('page:finish', () => nextTick(() => ScrollTrigger.refresh()))
50
+ nuxtApp.hook('page:finish', () =>
51
+ nextTick(() => {
52
+ instance.value?.scrollTo(0, { immediate: true })
53
+ ScrollTrigger.refresh()
54
+ })
55
+ )
51
56
  } else if (Array.isArray(smoothScroll)) {
52
57
  addRouteMiddleware((to, from) => {
53
58
  if (smoothScroll.includes(to.path)) {
@@ -0,0 +1,10 @@
1
+ export default defineAppConfig({})
2
+
3
+ declare module '@nuxt/schema' {
4
+ interface AppConfigInput {
5
+ layers?: Record<string, boolean>
6
+ }
7
+ interface AppConfig {
8
+ layers?: Record<string, boolean>
9
+ }
10
+ }
@@ -11,36 +11,45 @@ gsap.registerPlugin(ScrollTrigger)
11
11
  const heroRef = ref<HTMLElement | null>(null)
12
12
  const sectionsRef = ref<HTMLElement | null>(null)
13
13
 
14
+ let ctx: ReturnType<typeof gsap.context> | null = null
15
+
14
16
  onMounted(() => {
15
- if (heroRef.value) {
16
- gsap.from(Array.from(heroRef.value.children), {
17
- y: 24,
18
- opacity: 0,
19
- duration: 0.7,
20
- stagger: 0.1,
21
- ease: 'power3.out',
22
- })
23
- }
17
+ ctx = gsap.context(() => {
18
+ if (heroRef.value) {
19
+ gsap.from(Array.from(heroRef.value.children), {
20
+ y: 24,
21
+ opacity: 0,
22
+ duration: 0.7,
23
+ stagger: 0.1,
24
+ ease: 'power3.out',
25
+ })
26
+ }
27
+
28
+ if (sectionsRef.value) {
29
+ gsap.from(sectionsRef.value.querySelectorAll('.layer-card'), {
30
+ scrollTrigger: {
31
+ trigger: sectionsRef.value,
32
+ start: 'top 85%',
33
+ toggleActions: 'play none none none',
34
+ },
35
+ y: 32,
36
+ opacity: 0,
37
+ scale: 0.97,
38
+ duration: 0.5,
39
+ stagger: { amount: 0.5 },
40
+ ease: 'power3.out',
41
+ })
42
+ }
43
+ })
44
+ })
24
45
 
25
- if (sectionsRef.value) {
26
- gsap.from(sectionsRef.value.querySelectorAll('.layer-card'), {
27
- scrollTrigger: {
28
- trigger: sectionsRef.value,
29
- start: 'top 85%',
30
- toggleActions: 'play none none none',
31
- },
32
- y: 32,
33
- opacity: 0,
34
- scale: 0.97,
35
- duration: 0.5,
36
- stagger: { amount: 0.5 },
37
- ease: 'power3.out',
38
- })
39
- }
46
+ onUnmounted(() => {
47
+ ctx?.revert()
48
+ ctx = null
40
49
  })
41
50
 
42
51
  const appConfig = useAppConfig()
43
- const activeLayers = appConfig.layers as Record<string, boolean>
52
+ const activeLayers = appConfig.layers ?? {}
44
53
 
45
54
  type LayerDef = {
46
55
  name: string
@@ -1,8 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import { useThemePreferenceModels } from '../../composables/useThemePreferenceModels'
3
3
 
4
- const { contrastOverride, motionOverride, transparencyOverride, contrastModel, motionModel, transparencyModel } =
5
- useThemePreferenceModels()
4
+ const {
5
+ contrastOverride,
6
+ motionOverride,
7
+ transparencyOverride,
8
+ contrastModel,
9
+ motionModel,
10
+ transparencyModel,
11
+ } = useThemePreferenceModels()
6
12
  </script>
7
13
 
8
14
  <template>
@@ -1,4 +1,11 @@
1
1
  // Theme Layer - Color mode, accent colors, accessibility preferences
2
+
3
+ declare module '@nuxt/schema' {
4
+ type PublicRuntimeConfig = {
5
+ themeDefaultAccent: string
6
+ }
7
+ }
8
+
2
9
  export default defineNuxtConfig({
3
10
  $meta: {
4
11
  name: 'theme',
@@ -6,6 +13,12 @@ export default defineNuxtConfig({
6
13
 
7
14
  extends: ['../core'],
8
15
 
16
+ runtimeConfig: {
17
+ public: {
18
+ themeDefaultAccent: 'blue',
19
+ },
20
+ },
21
+
9
22
  alias: {
10
23
  '#layers/theme': import.meta.dirname,
11
24
  '#layers/theme/types': `${import.meta.dirname}/app/types`,
@@ -1,63 +1,12 @@
1
1
  import { buildAccentCSS } from '../utils/accent-css'
2
-
3
- /**
4
- * Nitro render hook — prevents FOUC for all theme preferences by injecting:
5
- *
6
- * 1. A `<style>` tag with CSS rules mapping each `[data-theme-colour='X']` to
7
- * the correct `--ui-color-primary-*`, `--ui-color-secondary-*`, and
8
- * `--ui-color-info-*` values. Each accent selects a coordinated three-colour
9
- * palette so primary, secondary, and accent (info) all change together.
10
- *
11
- * 2. A blocking `<script>` that reads localStorage and restores all
12
- * `data-theme-*` attributes on `<html>` before first paint:
13
- * - `data-theme-colour` from `theme-colour` (defaults to 'blue')
14
- * - `data-theme-contrast` from `theme-contrast`
15
- * - `data-theme-motion` from `theme-motion`
16
- * - `data-theme-transparency` from `theme-transparency`
17
- * - `data-theme-mode` from `theme-mode` (raw string, not JSON — set by Nuxt Color Mode)
18
- *
19
- * Our script runs before CSS link tags (via head.unshift), so setting data-theme-mode
20
- * here prevents the dark-mode FOUC that would otherwise occur when Nuxt Color Mode's
21
- * script runs after stylesheets have downloaded. Nuxt Color Mode's script will then
22
- * set the same value again — this is idempotent and safe.
23
- */
2
+ import { buildThemeInitScript } from '../utils/fouc-config'
24
3
 
25
4
  const accentCSS = buildAccentCSS()
26
5
 
27
- // Blocking init script — restores data-* attributes from localStorage before first paint.
28
- // Written as a self-invoking function to avoid polluting the global scope.
29
- // JSON.parse handles the quoted string that useLocalStorage writes.
30
- // Note: theme-mode is stored as a raw string by Nuxt Color Mode (no JSON.stringify).
31
- const initScript = `(function(){
32
- try{
33
- var h=document.documentElement;
34
- var c=localStorage.getItem('theme-colour');
35
- h.setAttribute('data-theme-colour',c?JSON.parse(c):'blue');
36
- var ct=localStorage.getItem('theme-contrast');
37
- var ctv=ct?JSON.parse(ct):'system';
38
- if(ctv==='on'){h.setAttribute('data-theme-contrast','high')}
39
- else if(ctv==='off'){h.setAttribute('data-theme-contrast','standard')}
40
- else{h.setAttribute('data-theme-contrast',(window.matchMedia&&window.matchMedia('(prefers-contrast:more)').matches)?'high':'standard')}
41
- var m=localStorage.getItem('theme-motion');
42
- var mv=m?JSON.parse(m):'system';
43
- if(mv==='on'){h.setAttribute('data-theme-motion','reduced')}
44
- else if(mv==='off'){h.setAttribute('data-theme-motion','full')}
45
- else{h.setAttribute('data-theme-motion',(window.matchMedia&&window.matchMedia('(prefers-reduced-motion:reduce)').matches)?'reduced':'full')}
46
- var t=localStorage.getItem('theme-transparency');
47
- var tv=t?JSON.parse(t):'system';
48
- if(tv==='on'){h.setAttribute('data-theme-transparency','reduced')}
49
- else if(tv==='off'){h.setAttribute('data-theme-transparency','full')}
50
- else{h.setAttribute('data-theme-transparency',(window.matchMedia&&window.matchMedia('(prefers-reduced-transparency:reduce)').matches)?'reduced':'full')}
51
- var dm=localStorage.getItem('theme-mode');
52
- var dmv=dm||'system';
53
- if(dmv==='dark'){h.setAttribute('data-theme-mode','dark')}
54
- else if(dmv==='light'){h.setAttribute('data-theme-mode','light')}
55
- else{h.setAttribute('data-theme-mode',(window.matchMedia&&window.matchMedia('(prefers-color-scheme:dark)').matches)?'dark':'light')}
56
- }catch(e){}
57
- })()`.replace(/\n\s*/g, '')
58
-
59
6
  export default defineNitroPlugin((nitroApp) => {
60
- nitroApp.hooks.hook('render:html', (html) => {
7
+ nitroApp.hooks.hook('render:html', (html, { event }) => {
8
+ const config = useRuntimeConfig(event)
9
+ const initScript = buildThemeInitScript(config.public.themeDefaultAccent || 'blue')
61
10
  html.head.unshift(
62
11
  `<style id="theme-accent-css">${accentCSS}</style><script>${initScript}</script>`
63
12
  )
@@ -0,0 +1,85 @@
1
+ type TriStatePreference = {
2
+ varName: string
3
+ storageKey: string
4
+ attribute: string
5
+ jsonParsed: boolean
6
+ conditionA: string
7
+ valueA: string
8
+ conditionB: string
9
+ valueB: string
10
+ mediaQuery: string
11
+ mediaTrue: string
12
+ mediaFalse: string
13
+ }
14
+
15
+ // Each entry produces one tri-state block in the FOUC init script.
16
+ // Adding a new theme preference = adding one entry here.
17
+ const THEME_PREFERENCES: TriStatePreference[] = [
18
+ {
19
+ varName: 'ct',
20
+ storageKey: 'theme-contrast',
21
+ attribute: 'data-theme-contrast',
22
+ jsonParsed: true,
23
+ conditionA: 'on', valueA: 'high',
24
+ conditionB: 'off', valueB: 'standard',
25
+ mediaQuery: '(prefers-contrast:more)',
26
+ mediaTrue: 'high', mediaFalse: 'standard',
27
+ },
28
+ {
29
+ varName: 'm',
30
+ storageKey: 'theme-motion',
31
+ attribute: 'data-theme-motion',
32
+ jsonParsed: true,
33
+ conditionA: 'on', valueA: 'reduced',
34
+ conditionB: 'off', valueB: 'full',
35
+ mediaQuery: '(prefers-reduced-motion:reduce)',
36
+ mediaTrue: 'reduced', mediaFalse: 'full',
37
+ },
38
+ {
39
+ varName: 't',
40
+ storageKey: 'theme-transparency',
41
+ attribute: 'data-theme-transparency',
42
+ jsonParsed: true,
43
+ conditionA: 'on', valueA: 'reduced',
44
+ conditionB: 'off', valueB: 'full',
45
+ mediaQuery: '(prefers-reduced-transparency:reduce)',
46
+ mediaTrue: 'reduced', mediaFalse: 'full',
47
+ },
48
+ {
49
+ varName: 'dm',
50
+ storageKey: 'theme-mode',
51
+ attribute: 'data-theme-mode',
52
+ jsonParsed: false,
53
+ conditionA: 'dark', valueA: 'dark',
54
+ conditionB: 'light', valueB: 'light',
55
+ mediaQuery: '(prefers-color-scheme:dark)',
56
+ mediaTrue: 'dark', mediaFalse: 'light',
57
+ },
58
+ ]
59
+
60
+ function buildPrefFragment(p: TriStatePreference): string {
61
+ const readValue = p.jsonParsed
62
+ ? `${p.varName}?JSON.parse(${p.varName}):'system'`
63
+ : `${p.varName}||'system'`
64
+ return (
65
+ `var ${p.varName}=localStorage.getItem('${p.storageKey}');` +
66
+ `var ${p.varName}v=${readValue};` +
67
+ `if(${p.varName}v==='${p.conditionA}'){h.setAttribute('${p.attribute}','${p.valueA}')}` +
68
+ `else if(${p.varName}v==='${p.conditionB}'){h.setAttribute('${p.attribute}','${p.valueB}')}` +
69
+ `else{h.setAttribute('${p.attribute}',(window.matchMedia&&window.matchMedia('${p.mediaQuery}').matches)?'${p.mediaTrue}':'${p.mediaFalse}')}`
70
+ )
71
+ }
72
+
73
+ export function buildThemeInitScript(defaultAccent: string): string {
74
+ const colourBlock =
75
+ `var c=localStorage.getItem('theme-colour');` +
76
+ `h.setAttribute('data-theme-colour',c?JSON.parse(c):'${defaultAccent}');`
77
+
78
+ return (
79
+ '(function(){try{' +
80
+ 'var h=document.documentElement;' +
81
+ colourBlock +
82
+ THEME_PREFERENCES.map(buildPrefFragment).join('') +
83
+ '}catch(e){}})()'
84
+ )
85
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kmcom-nuxt-layers",
3
3
  "private": false,
4
- "version": "2.3.0",
4
+ "version": "2.3.2",
5
5
  "description": "Composable Nuxt 4 layers for building scalable Vue applications",
6
6
  "exports": {
7
7
  "./layers/core": "./layers/core/nuxt.config.ts",
@@ -1,28 +0,0 @@
1
- import type { Collections } from '@nuxt/content'
2
-
3
- type CollectionItem<TCollection extends keyof Collections> = Collections[TCollection]
4
-
5
- export function useCollectionItems<TCollection extends keyof Collections>(args: {
6
- key: string
7
- collection: TCollection
8
- sort: (a: CollectionItem<TCollection>, b: CollectionItem<TCollection>) => number
9
- options?: { limit?: number | undefined }
10
- filter?: (item: CollectionItem<TCollection>) => boolean
11
- }) {
12
- const { key, collection, sort, options = {}, filter } = args
13
-
14
- return useContentData(key, async () => {
15
- let items = (await queryCollection(collection).all()) as CollectionItem<TCollection>[]
16
- items = items.sort(sort)
17
-
18
- if (filter) {
19
- items = items.filter(filter)
20
- }
21
-
22
- if (options.limit !== undefined) {
23
- items = items.slice(0, options.limit)
24
- }
25
-
26
- return items
27
- })
28
- }