kmcom-nuxt-layers 2.2.11 → 2.2.12

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 (37) hide show
  1. package/docs/FALLOW-COMPLEXITY-DUPLICATION-AUDIT.md +65 -0
  2. package/docs/IMPROVE-AUDIT-README.md +30 -0
  3. package/docs/IMPROVE-AUDIT-RESULTS.md +52 -0
  4. package/docs/IMPROVE-DEEP-AUDIT-RESULTS.md +81 -0
  5. package/docs/fallow-refactor/apps-debug.md +27 -0
  6. package/docs/fallow-refactor/apps-playground.md +46 -0
  7. package/docs/fallow-refactor/apps-visual-identity.md +41 -0
  8. package/docs/fallow-refactor/layers-animations.md +34 -0
  9. package/docs/fallow-refactor/layers-canvas.md +32 -0
  10. package/docs/fallow-refactor/layers-content.md +33 -0
  11. package/docs/fallow-refactor/layers-core.md +39 -0
  12. package/docs/fallow-refactor/layers-feeds.md +39 -0
  13. package/docs/fallow-refactor/layers-forms.md +30 -0
  14. package/docs/fallow-refactor/layers-layout.md +42 -0
  15. package/docs/fallow-refactor/layers-mailer.md +32 -0
  16. package/docs/fallow-refactor/layers-motion.md +27 -0
  17. package/docs/fallow-refactor/layers-navigation.md +31 -0
  18. package/docs/fallow-refactor/layers-page-transitions.md +30 -0
  19. package/docs/fallow-refactor/layers-routing.md +33 -0
  20. package/docs/fallow-refactor/layers-scripts.md +35 -0
  21. package/docs/fallow-refactor/layers-scroll.md +38 -0
  22. package/docs/fallow-refactor/layers-seo.md +32 -0
  23. package/docs/fallow-refactor/layers-shader.md +53 -0
  24. package/docs/fallow-refactor/layers-theme.md +33 -0
  25. package/docs/fallow-refactor/layers-transitions.md +27 -0
  26. package/docs/fallow-refactor/layers-typography.md +29 -0
  27. package/docs/fallow-refactor/layers-ui.md +27 -0
  28. package/docs/fallow-refactor/layers-visual.md +34 -0
  29. package/layers/feeds/app/app.config.ts +4 -2
  30. package/layers/feeds/app/components/Feeds/Index.vue +229 -0
  31. package/layers/feeds/app/components/Feeds/RouteCard.vue +81 -0
  32. package/layers/feeds/app/plugins/feed-head.ts +27 -49
  33. package/layers/feeds/app/utils/feed-catalog.test.ts +71 -0
  34. package/layers/feeds/app/utils/feed-catalog.ts +179 -0
  35. package/layers/feeds/package.json +1 -0
  36. package/layers/feeds/server/routes/feed/discovery.get.ts +16 -14
  37. package/package.json +3 -2
@@ -0,0 +1,229 @@
1
+ <script setup lang="ts">
2
+ import contentManifest from '#content/manifest'
3
+
4
+ import { createFeedCatalog } from '../../utils/feed-catalog'
5
+
6
+ const appConfig = useAppConfig()
7
+
8
+ const catalog = computed(() =>
9
+ createFeedCatalog({
10
+ site: appConfig.site,
11
+ feed: appConfig.feedsLayer?.feed,
12
+ manifest: contentManifest,
13
+ })
14
+ )
15
+ </script>
16
+
17
+ <template>
18
+ <div
19
+ class="relative overflow-hidden rounded-4xl border border-amber-200/70 bg-[linear-gradient(180deg,rgba(255,251,244,0.96),rgba(243,236,223,0.95))] px-5 py-5 shadow-[0_30px_100px_rgba(15,23,42,0.12)] dark:border-slate-800/80 dark:bg-[linear-gradient(180deg,rgba(2,6,23,0.96),rgba(3,7,18,0.98))] sm:px-6 sm:py-6"
20
+ >
21
+ <div class="pointer-events-none absolute inset-0">
22
+ <div
23
+ class="absolute -right-20 top-0 h-72 w-72 rounded-full bg-orange-400/20 blur-3xl dark:bg-orange-500/15"
24
+ />
25
+ <div
26
+ class="absolute -left-20 bottom-0 h-80 w-80 rounded-full bg-sky-400/12 blur-3xl dark:bg-sky-500/10"
27
+ />
28
+ <div
29
+ class="absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-orange-400/35 to-transparent"
30
+ />
31
+ </div>
32
+
33
+ <div class="relative space-y-8">
34
+ <div class="grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(18rem,0.85fr)]">
35
+ <div class="space-y-6">
36
+ <div
37
+ class="inline-flex items-center gap-2 rounded-full border border-amber-200/80 bg-white/80 px-3 py-1 text-[0.68rem] font-semibold uppercase tracking-[0.35em] text-amber-700 shadow-sm backdrop-blur dark:border-slate-700 dark:bg-slate-900/60 dark:text-amber-200"
38
+ >
39
+ <UIcon name="i-lucide-rss" class="text-sm" />
40
+ Feeds layer
41
+ </div>
42
+
43
+ <div class="space-y-3">
44
+ <h1
45
+ class="max-w-3xl text-4xl font-semibold tracking-tight text-slate-950 sm:text-5xl md:text-6xl dark:text-slate-50"
46
+ >
47
+ Feed catalog for {{ catalog.site.title }}
48
+ </h1>
49
+ <p class="max-w-2xl text-base leading-7 text-slate-700 sm:text-lg dark:text-slate-300">
50
+ Configured in <code class="font-mono text-[0.9em]">app.config.ts</code>, validated
51
+ against <code class="font-mono text-[0.9em]">content.config.ts</code>, and rendered as
52
+ a human-readable index for readers and site owners.
53
+ </p>
54
+ </div>
55
+
56
+ <div class="grid gap-3 sm:grid-cols-3">
57
+ <div
58
+ class="rounded-2xl border border-slate-200/80 bg-white/75 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/45"
59
+ >
60
+ <p
61
+ class="text-[0.68rem] font-semibold uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400"
62
+ >
63
+ Default
64
+ </p>
65
+ <p class="mt-2 text-lg font-semibold text-slate-950 dark:text-slate-100">
66
+ {{ catalog.feed.defaultCollection }}
67
+ </p>
68
+ <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
69
+ Shorthand routes point here.
70
+ </p>
71
+ </div>
72
+
73
+ <div
74
+ class="rounded-2xl border border-slate-200/80 bg-white/75 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/45"
75
+ >
76
+ <p
77
+ class="text-[0.68rem] font-semibold uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400"
78
+ >
79
+ Exposed
80
+ </p>
81
+ <p class="mt-2 text-lg font-semibold text-slate-950 dark:text-slate-100">
82
+ {{ catalog.collectionGroups.length }} collections
83
+ </p>
84
+ <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
85
+ Published in the feed catalog.
86
+ </p>
87
+ </div>
88
+
89
+ <div
90
+ class="rounded-2xl border border-slate-200/80 bg-white/75 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/45"
91
+ >
92
+ <p
93
+ class="text-[0.68rem] font-semibold uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400"
94
+ >
95
+ Limit
96
+ </p>
97
+ <p class="mt-2 text-lg font-semibold text-slate-950 dark:text-slate-100">
98
+ {{ catalog.feed.limit }} items
99
+ </p>
100
+ <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
101
+ Applied to each feed response.
102
+ </p>
103
+ </div>
104
+ </div>
105
+
106
+ <div
107
+ v-if="catalog.site.url || catalog.site.description"
108
+ class="flex flex-wrap items-center gap-3 rounded-2xl border border-slate-200/80 bg-white/70 px-4 py-3 text-sm shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/45"
109
+ >
110
+ <span
111
+ class="text-xs font-semibold uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400"
112
+ >
113
+ Site
114
+ </span>
115
+ <span v-if="catalog.site.description" class="text-slate-700 dark:text-slate-300">
116
+ {{ catalog.site.description }}
117
+ </span>
118
+ <code
119
+ v-if="catalog.site.url"
120
+ class="rounded-full bg-slate-100 px-2 py-1 font-mono text-[0.72rem] text-slate-600 dark:bg-slate-800 dark:text-slate-300"
121
+ >
122
+ {{ catalog.site.url }}
123
+ </code>
124
+ </div>
125
+ </div>
126
+
127
+ <aside
128
+ class="rounded-[1.5rem] border border-slate-200/80 bg-white/75 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/45"
129
+ >
130
+ <div class="flex items-center justify-between gap-3">
131
+ <p
132
+ class="text-[0.68rem] font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400"
133
+ >
134
+ Global routes
135
+ </p>
136
+ <UBadge color="neutral" variant="subtle" size="xs">
137
+ {{ catalog.siteRoutes.length }} links
138
+ </UBadge>
139
+ </div>
140
+
141
+ <div class="mt-4 space-y-3">
142
+ <FeedsRouteCard v-for="route in catalog.siteRoutes" :key="route.path" :route />
143
+ </div>
144
+ </aside>
145
+ </div>
146
+
147
+ <UAlert
148
+ v-if="catalog.feed.missingCollections.length"
149
+ color="warning"
150
+ variant="soft"
151
+ icon="i-lucide-triangle-alert"
152
+ title="Feed config references collections that content.config.ts does not define"
153
+ :description="`Missing collections: ${catalog.feed.missingCollections.join(', ')}. Add them to content.config.ts or remove them from feedsLayer.feed.collections.`"
154
+ />
155
+
156
+ <div class="space-y-4">
157
+ <div class="flex flex-wrap items-end justify-between gap-4">
158
+ <div>
159
+ <p
160
+ class="text-[0.68rem] font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400"
161
+ >
162
+ Collection routes
163
+ </p>
164
+ <h2
165
+ class="mt-2 text-2xl font-semibold tracking-tight text-slate-950 dark:text-slate-100"
166
+ >
167
+ Each exposed collection gets RSS, Atom, and JSON.
168
+ </h2>
169
+ </div>
170
+ <p class="max-w-lg text-sm text-slate-600 dark:text-slate-400">
171
+ Add or remove collections in <code class="font-mono text-[0.9em]">app.config.ts</code>
172
+ and the catalog updates automatically.
173
+ </p>
174
+ </div>
175
+
176
+ <div v-if="catalog.collectionGroups.length" class="space-y-4">
177
+ <UCard
178
+ v-for="group in catalog.collectionGroups"
179
+ :key="group.collection"
180
+ :ui="{ body: 'p-0' }"
181
+ class="overflow-hidden border-slate-200/80 bg-white/80 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-950/45"
182
+ >
183
+ <div
184
+ class="flex flex-wrap items-start justify-between gap-4 border-b border-slate-200/80 px-5 py-4 dark:border-slate-800"
185
+ >
186
+ <div>
187
+ <p
188
+ class="text-[0.68rem] font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400"
189
+ >
190
+ Collection
191
+ </p>
192
+ <h3 class="mt-2 text-xl font-semibold text-slate-950 dark:text-slate-100">
193
+ {{ group.label }}
194
+ </h3>
195
+ <p class="mt-1 font-mono text-sm text-slate-500 dark:text-slate-400">
196
+ {{ group.collection }}
197
+ </p>
198
+ </div>
199
+ <UBadge color="neutral" variant="subtle" size="sm">
200
+ {{ group.routes.length }} formats
201
+ </UBadge>
202
+ </div>
203
+
204
+ <div class="grid gap-3 p-5 sm:grid-cols-3">
205
+ <FeedsRouteCard v-for="route in group.routes" :key="route.path" :route compact />
206
+ </div>
207
+ </UCard>
208
+ </div>
209
+
210
+ <UCard
211
+ v-else
212
+ :ui="{ body: 'p-0' }"
213
+ class="border-dashed border-slate-300 bg-white/70 shadow-none dark:border-slate-700 dark:bg-slate-950/40"
214
+ >
215
+ <div class="space-y-2 p-5">
216
+ <p class="text-sm font-medium text-slate-950 dark:text-slate-100">
217
+ No collection feeds are currently exposed.
218
+ </p>
219
+ <p class="text-sm text-slate-600 dark:text-slate-400">
220
+ Add page collections to
221
+ <code class="font-mono text-[0.9em]">feedsLayer.feed.collections</code>
222
+ in <code class="font-mono text-[0.9em]">app.config.ts</code>.
223
+ </p>
224
+ </div>
225
+ </UCard>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </template>
@@ -0,0 +1,81 @@
1
+ <script setup lang="ts">
2
+ import { type FeedRoute } from '../../utils/feed-catalog'
3
+
4
+ const { route, compact = false } = defineProps<{
5
+ route: FeedRoute
6
+ compact?: boolean
7
+ }>()
8
+
9
+ const routeThemes: Record<
10
+ 'index' | 'discovery' | 'rss' | 'atom' | 'json',
11
+ {
12
+ strip: string
13
+ dot: string
14
+ }
15
+ > = {
16
+ index: {
17
+ strip: 'bg-slate-400',
18
+ dot: 'bg-slate-500',
19
+ },
20
+ discovery: {
21
+ strip: 'bg-sky-400',
22
+ dot: 'bg-sky-500',
23
+ },
24
+ rss: {
25
+ strip: 'bg-orange-400',
26
+ dot: 'bg-orange-500',
27
+ },
28
+ atom: {
29
+ strip: 'bg-violet-400',
30
+ dot: 'bg-violet-500',
31
+ },
32
+ json: {
33
+ strip: 'bg-amber-400',
34
+ dot: 'bg-amber-500',
35
+ },
36
+ }
37
+
38
+ const routeTone = computed(
39
+ () => routeThemes[route.kind === 'format' ? (route.format ?? 'index') : route.kind]
40
+ )
41
+
42
+ function getRouteDescription() {
43
+ if (route.kind === 'index') {
44
+ return 'Human landing page for the feed catalog.'
45
+ }
46
+
47
+ if (route.kind === 'discovery') {
48
+ return 'JSON manifest of every exposed collection feed.'
49
+ }
50
+
51
+ return route.contentType ?? 'Reader-friendly syndicated feed.'
52
+ }
53
+ </script>
54
+
55
+ <template>
56
+ <UCard
57
+ :to="route.path"
58
+ :ui="{ body: 'p-0' }"
59
+ class="group relative overflow-hidden border-slate-200/80 bg-white/90 shadow-none transition duration-200 hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md dark:border-slate-800 dark:bg-slate-950/55 dark:hover:border-slate-700"
60
+ >
61
+ <div class="absolute inset-y-0 left-0 w-1" :class="routeTone.strip" />
62
+ <div :class="compact ? 'flex items-center gap-3 p-3' : 'flex items-center gap-3 p-4'">
63
+ <span class="h-2.5 w-2.5 rounded-full" :class="routeTone.dot" />
64
+ <div class="min-w-0 flex-1">
65
+ <div class="flex items-start justify-between gap-3">
66
+ <p class="font-medium text-slate-950 dark:text-slate-100">
67
+ {{ route.label }}
68
+ </p>
69
+ <code
70
+ class="shrink-0 rounded-full bg-slate-100 px-2 py-1 font-mono text-[0.72rem] text-slate-600 dark:bg-slate-800 dark:text-slate-300"
71
+ >
72
+ {{ route.path }}
73
+ </code>
74
+ </div>
75
+ <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
76
+ {{ getRouteDescription() }}
77
+ </p>
78
+ </div>
79
+ </div>
80
+ </UCard>
81
+ </template>
@@ -1,71 +1,49 @@
1
+ import contentManifest from '#content/manifest'
2
+
3
+ import { createFeedCatalog } from '../utils/feed-catalog'
4
+
1
5
  export default defineNuxtPlugin({
2
6
  name: 'feeds:feed-head',
3
7
  setup() {
4
8
  const appConfig = useAppConfig()
5
9
  const site = appConfig.site ?? {}
6
- const feedConfig = appConfig.feedsLayer?.feed ?? {}
7
- const collections: string[] = feedConfig.collections ?? ['blog']
8
- const defaultCollection: string = feedConfig.defaultCollection ?? 'blog'
9
10
  const siteTitle: string = site.title ?? ''
11
+ const catalog = createFeedCatalog({
12
+ site: site,
13
+ feed: appConfig.feedsLayer?.feed,
14
+ manifest: contentManifest,
15
+ })
10
16
 
11
17
  const route = useRoute()
12
18
 
13
19
  useHead(() => {
14
20
  // Derive which collection (if any) the current page belongs to
15
21
  const segment = route.path.split('/').filter(Boolean)[0] ?? ''
16
- const currentCollection = collections.includes(segment) ? segment : null
22
+ const currentCollection = catalog.collectionGroups.find(
23
+ (group) => group.collection === segment
24
+ )
17
25
 
18
26
  // Always present: main site feeds via the shorthand routes
19
- const mainLinks = [
20
- {
21
- rel: 'alternate',
22
- type: 'application/rss+xml',
23
- title: `${siteTitle || 'Site'} (RSS)`,
24
- href: '/feed/rss',
25
- },
26
- {
27
+ const mainLinks = catalog.siteRoutes
28
+ .filter((route) => route.kind === 'format')
29
+ .map((route) => ({
27
30
  rel: 'alternate',
28
- type: 'application/atom+xml',
29
- title: `${siteTitle || 'Site'} (Atom)`,
30
- href: '/feed/atom',
31
- },
32
- {
33
- rel: 'alternate',
34
- type: 'application/feed+json',
35
- title: `${siteTitle || 'Site'} (JSON Feed)`,
36
- href: '/feed/json',
37
- },
38
- ]
31
+ type: route.contentType ?? 'application/octet-stream',
32
+ title: `${siteTitle || 'Site'} (${route.label})`,
33
+ href: route.path,
34
+ }))
39
35
 
40
36
  // Collection-specific feeds — only when on a non-default collection's pages.
41
37
  // The default collection is already covered by the main shorthand links above.
42
- const label =
43
- currentCollection && currentCollection !== defaultCollection
44
- ? currentCollection.charAt(0).toUpperCase() + currentCollection.slice(1)
45
- : null
46
-
47
- const collectionLinks = label
48
- ? [
49
- {
50
- rel: 'alternate',
51
- type: 'application/rss+xml',
52
- title: `${label} (RSS)`,
53
- href: `/feed/${currentCollection}/rss`,
54
- },
55
- {
56
- rel: 'alternate',
57
- type: 'application/atom+xml',
58
- title: `${label} (Atom)`,
59
- href: `/feed/${currentCollection}/atom`,
60
- },
61
- {
38
+ const collectionLinks =
39
+ currentCollection && currentCollection.collection !== catalog.feed.defaultCollection
40
+ ? currentCollection.routes.map((route) => ({
62
41
  rel: 'alternate',
63
- type: 'application/feed+json',
64
- title: `${label} (JSON Feed)`,
65
- href: `/feed/${currentCollection}/json`,
66
- },
67
- ]
68
- : []
42
+ type: route.contentType ?? 'application/octet-stream',
43
+ title: `${currentCollection.label} (${route.label})`,
44
+ href: route.path,
45
+ }))
46
+ : []
69
47
 
70
48
  return { link: [...mainLinks, ...collectionLinks] }
71
49
  })
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { createFeedCatalog } from './feed-catalog'
4
+
5
+ describe('createFeedCatalog', () => {
6
+ it('builds the feed manifest from app config and content collections', () => {
7
+ const catalog = createFeedCatalog({
8
+ site: {
9
+ title: 'Nuxt Layers Playground',
10
+ description: 'Demo and development playground for nuxt-layers.',
11
+ url: 'https://nuxtlayers.netlify.app/',
12
+ },
13
+ feed: {
14
+ collections: ['blog', 'portfolio', 'gallery'],
15
+ defaultCollection: 'blog',
16
+ limit: 30,
17
+ },
18
+ manifest: {
19
+ content: { type: 'page' },
20
+ blog: { type: 'page' },
21
+ portfolio: { type: 'page' },
22
+ gallery: { type: 'page' },
23
+ info: { type: 'data' },
24
+ },
25
+ })
26
+
27
+ expect(catalog.site.title).toBe('Nuxt Layers Playground')
28
+ expect(catalog.feed.defaultCollection).toBe('blog')
29
+ expect(catalog.feed.limit).toBe(30)
30
+ expect(catalog.feed.collections).toEqual(['blog', 'portfolio', 'gallery'])
31
+ expect(catalog.feed.availableCollections).toEqual(['content', 'blog', 'portfolio', 'gallery'])
32
+ expect(catalog.feed.missingCollections).toEqual([])
33
+ expect(catalog.siteRoutes.map((route) => route.path)).toEqual([
34
+ '/feed',
35
+ '/feed/discovery',
36
+ '/feed/rss',
37
+ '/feed/atom',
38
+ '/feed/json',
39
+ ])
40
+ expect(catalog.collectionGroups).toHaveLength(3)
41
+ expect(catalog.collectionGroups[1]).toMatchObject({
42
+ collection: 'portfolio',
43
+ label: 'Portfolio',
44
+ })
45
+ expect(catalog.collectionGroups[1]?.routes.map((route) => route.path)).toEqual([
46
+ '/feed/portfolio/rss',
47
+ '/feed/portfolio/atom',
48
+ '/feed/portfolio/json',
49
+ ])
50
+ })
51
+
52
+ it('surfaces configured collections that do not exist in content', () => {
53
+ const catalog = createFeedCatalog({
54
+ feed: {
55
+ collections: ['blog', 'missing'],
56
+ defaultCollection: 'missing',
57
+ },
58
+ manifest: {
59
+ blog: { type: 'page' },
60
+ gallery: { type: 'page' },
61
+ },
62
+ })
63
+
64
+ expect(catalog.feed.missingCollections).toEqual(['missing'])
65
+ expect(catalog.collectionGroups).toHaveLength(1)
66
+ expect(catalog.collectionGroups[0]).toMatchObject({
67
+ collection: 'blog',
68
+ label: 'Blog',
69
+ })
70
+ })
71
+ })
@@ -0,0 +1,179 @@
1
+ export type FeedFormatKey = 'rss' | 'atom' | 'json'
2
+
3
+ export type FeedManifestEntry = {
4
+ type: string
5
+ [key: string]: unknown
6
+ }
7
+
8
+ export type FeedManifest = Record<string, FeedManifestEntry | undefined>
9
+
10
+ export type FeedCatalogSiteInput = {
11
+ title?: string
12
+ description?: string
13
+ url?: string
14
+ author?: {
15
+ name?: string
16
+ }
17
+ }
18
+
19
+ export type FeedCatalogInput = {
20
+ site?: FeedCatalogSiteInput
21
+ feed?: {
22
+ collections?: readonly string[]
23
+ defaultCollection?: string
24
+ limit?: number
25
+ }
26
+ manifest?: FeedManifest
27
+ }
28
+
29
+ export type FeedRoute = {
30
+ kind: 'index' | 'discovery' | 'format'
31
+ label: string
32
+ path: string
33
+ format?: FeedFormatKey
34
+ contentType?: string
35
+ }
36
+
37
+ export type FeedCollectionGroup = {
38
+ collection: string
39
+ label: string
40
+ routes: FeedRoute[]
41
+ }
42
+
43
+ export type FeedCatalog = {
44
+ site: {
45
+ title: string
46
+ description: string
47
+ url: string
48
+ author?: {
49
+ name: string
50
+ }
51
+ }
52
+ feed: {
53
+ collections: string[]
54
+ defaultCollection: string
55
+ limit: number
56
+ availableCollections: string[]
57
+ missingCollections: string[]
58
+ }
59
+ siteRoutes: FeedRoute[]
60
+ collectionGroups: FeedCollectionGroup[]
61
+ }
62
+
63
+ type FeedState = FeedCatalog['feed']
64
+
65
+ const FEED_FORMATS: Array<{ key: FeedFormatKey; label: string; contentType: string }> = [
66
+ { key: 'rss', label: 'RSS 2.0', contentType: 'application/rss+xml' },
67
+ { key: 'atom', label: 'Atom 1.0', contentType: 'application/atom+xml' },
68
+ { key: 'json', label: 'JSON Feed 1.1', contentType: 'application/feed+json' },
69
+ ]
70
+
71
+ function unique(values: readonly string[]): string[] {
72
+ return [...new Set(values)]
73
+ }
74
+
75
+ export function formatFeedCollectionName(collection: string): string {
76
+ return collection
77
+ .replace(/[-_]+/g, ' ')
78
+ .split(' ')
79
+ .filter(Boolean)
80
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
81
+ .join(' ')
82
+ }
83
+
84
+ function normalizeSiteUrl(url?: string): string {
85
+ return url?.replace(/\/$/, '') ?? ''
86
+ }
87
+
88
+ function resolveAvailableCollections(manifest: FeedManifest | undefined): string[] {
89
+ if (!manifest) {
90
+ return []
91
+ }
92
+
93
+ return Object.entries(manifest)
94
+ .filter(([, collection]) => collection?.type === 'page')
95
+ .map(([name]) => name)
96
+ }
97
+
98
+ function resolveFeedState(
99
+ feed: FeedCatalogInput['feed'],
100
+ defaultCollection: string,
101
+ availableCollections: string[]
102
+ ): FeedState {
103
+ const collections = unique(feed?.collections ?? [defaultCollection])
104
+ const missingCollections = unique([defaultCollection, ...collections]).filter(
105
+ (collection) => !availableCollections.includes(collection)
106
+ )
107
+
108
+ return {
109
+ collections,
110
+ defaultCollection,
111
+ limit: feed?.limit ?? 30,
112
+ availableCollections,
113
+ missingCollections,
114
+ }
115
+ }
116
+
117
+ function resolveSiteRoutes(): FeedRoute[] {
118
+ return [
119
+ { kind: 'index', label: 'Feed index', path: '/feed' },
120
+ { kind: 'discovery', label: 'Discovery index', path: '/feed/discovery' },
121
+ ...FEED_FORMATS.map(
122
+ (format) =>
123
+ ({
124
+ kind: 'format',
125
+ label: format.label,
126
+ path: `/feed/${format.key}`,
127
+ format: format.key,
128
+ contentType: format.contentType,
129
+ }) satisfies FeedRoute
130
+ ),
131
+ ]
132
+ }
133
+
134
+ function resolveCollectionGroups(
135
+ collections: string[],
136
+ availableCollections: string[]
137
+ ): FeedCollectionGroup[] {
138
+ return collections
139
+ .filter((collection) => availableCollections.includes(collection))
140
+ .map((collection) => ({
141
+ collection,
142
+ label: formatFeedCollectionName(collection),
143
+ routes: FEED_FORMATS.map(
144
+ (format) =>
145
+ ({
146
+ kind: 'format',
147
+ label: format.label,
148
+ path: `/feed/${collection}/${format.key}`,
149
+ format: format.key,
150
+ contentType: format.contentType,
151
+ }) satisfies FeedRoute
152
+ ),
153
+ }))
154
+ }
155
+
156
+ export function createFeedCatalog(input: FeedCatalogInput = {}): FeedCatalog {
157
+ const site = input.site ?? {}
158
+ const defaultCollection = input.feed?.defaultCollection ?? 'blog'
159
+ const availableCollections = resolveAvailableCollections(input.manifest)
160
+ const feed = resolveFeedState(input.feed, defaultCollection, availableCollections)
161
+
162
+ return {
163
+ site: {
164
+ title: site.title ?? 'Site',
165
+ description: site.description ?? '',
166
+ url: normalizeSiteUrl(site.url),
167
+ ...(site.author?.name
168
+ ? {
169
+ author: {
170
+ name: site.author.name,
171
+ },
172
+ }
173
+ : {}),
174
+ },
175
+ feed,
176
+ siteRoutes: resolveSiteRoutes(),
177
+ collectionGroups: resolveCollectionGroups(feed.collections, availableCollections),
178
+ }
179
+ }
@@ -17,6 +17,7 @@
17
17
  "feed": "catalog:"
18
18
  },
19
19
  "devDependencies": {
20
+ "@nuxt/content": "catalog:",
20
21
  "nuxt": "catalog:",
21
22
  "vue": "catalog:conflicts_conflicts_vue_latest_h3_5_35",
22
23
  "vue-router": "catalog:"