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.
- package/docs/FALLOW-COMPLEXITY-DUPLICATION-AUDIT.md +65 -0
- package/docs/IMPROVE-AUDIT-README.md +30 -0
- package/docs/IMPROVE-AUDIT-RESULTS.md +52 -0
- package/docs/IMPROVE-DEEP-AUDIT-RESULTS.md +81 -0
- package/docs/fallow-refactor/apps-debug.md +27 -0
- package/docs/fallow-refactor/apps-playground.md +46 -0
- package/docs/fallow-refactor/apps-visual-identity.md +41 -0
- package/docs/fallow-refactor/layers-animations.md +34 -0
- package/docs/fallow-refactor/layers-canvas.md +32 -0
- package/docs/fallow-refactor/layers-content.md +33 -0
- package/docs/fallow-refactor/layers-core.md +39 -0
- package/docs/fallow-refactor/layers-feeds.md +39 -0
- package/docs/fallow-refactor/layers-forms.md +30 -0
- package/docs/fallow-refactor/layers-layout.md +42 -0
- package/docs/fallow-refactor/layers-mailer.md +32 -0
- package/docs/fallow-refactor/layers-motion.md +27 -0
- package/docs/fallow-refactor/layers-navigation.md +31 -0
- package/docs/fallow-refactor/layers-page-transitions.md +30 -0
- package/docs/fallow-refactor/layers-routing.md +33 -0
- package/docs/fallow-refactor/layers-scripts.md +35 -0
- package/docs/fallow-refactor/layers-scroll.md +38 -0
- package/docs/fallow-refactor/layers-seo.md +32 -0
- package/docs/fallow-refactor/layers-shader.md +53 -0
- package/docs/fallow-refactor/layers-theme.md +33 -0
- package/docs/fallow-refactor/layers-transitions.md +27 -0
- package/docs/fallow-refactor/layers-typography.md +29 -0
- package/docs/fallow-refactor/layers-ui.md +27 -0
- package/docs/fallow-refactor/layers-visual.md +34 -0
- package/layers/feeds/app/app.config.ts +4 -2
- package/layers/feeds/app/components/Feeds/Index.vue +229 -0
- package/layers/feeds/app/components/Feeds/RouteCard.vue +81 -0
- package/layers/feeds/app/plugins/feed-head.ts +27 -49
- package/layers/feeds/app/utils/feed-catalog.test.ts +71 -0
- package/layers/feeds/app/utils/feed-catalog.ts +179 -0
- package/layers/feeds/package.json +1 -0
- package/layers/feeds/server/routes/feed/discovery.get.ts +16 -14
- 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 =
|
|
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
|
-
|
|
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/
|
|
29
|
-
title: `${siteTitle || 'Site'} (
|
|
30
|
-
href:
|
|
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
|
|
43
|
-
currentCollection && currentCollection !== defaultCollection
|
|
44
|
-
? currentCollection.
|
|
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/
|
|
64
|
-
title: `${label} (
|
|
65
|
-
href:
|
|
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
|
+
}
|