kmcom-nuxt-layers 2.2.11 → 2.2.13
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/FEEDS.md +1 -2
- 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/animations/app/composables/useMagneticElement.ts +11 -9
- package/layers/animations/app/composables/useTiltEffect.ts +11 -9
- package/layers/animations/app/utils/pointerMotion.ts +31 -0
- package/layers/canvas/app/components/ShaderCanvas.vue +2 -2
- package/layers/content/app/composables/useCollectionItems.ts +28 -0
- package/layers/content/app/composables/useGalleryItems.ts +8 -14
- package/layers/content/app/composables/usePortfolioItems.ts +10 -18
- package/layers/core/app/composables/useBrowser.ts +9 -82
- package/layers/core/app/composables/useFeatures.ts +3 -27
- package/layers/core/app/plugins/init.ts +157 -135
- package/layers/core/app/utils/browserInfo.ts +115 -0
- package/layers/core/app/utils/featureClasses.ts +40 -0
- package/layers/core/app/utils/helpers.test.ts +51 -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 +75 -0
- package/layers/feeds/app/plugins/feed-head.ts +27 -49
- package/layers/feeds/app/utils/feed-catalog.ts +184 -0
- package/layers/feeds/nuxt.config.ts +0 -1
- package/layers/feeds/package.json +1 -0
- package/layers/feeds/server/utils/content-adapter.test.ts +68 -0
- package/layers/feeds/server/utils/content-adapter.ts +2 -22
- package/layers/feeds/server/utils/feed-author.ts +32 -0
- package/layers/feeds/server/utils/feed-config.ts +88 -0
- package/layers/feeds/server/utils/feed-service.ts +11 -30
- package/layers/feeds/server/utils/feed-xml.ts +26 -0
- package/layers/feeds/server/utils/formats/rss.ts +10 -15
- package/layers/feeds/server/utils/formats.test.ts +71 -0
- package/layers/forms/app/components/Form/Field.vue +42 -30
- package/layers/forms/app/utils/fieldProps.ts +65 -0
- package/layers/layout/app/components/Layout/Grid/Item.vue +29 -146
- package/layers/layout/app/utils/gridPlacementStyle.ts +195 -0
- package/layers/mailer/app/types/mailer.ts +7 -25
- package/layers/mailer/server/utils/email.ts +28 -13
- package/layers/mailer/server/utils/hooks.ts +1 -20
- package/layers/navigation/app/composables/useSite.ts +2 -9
- package/layers/navigation/app/utils/site.ts +26 -0
- package/layers/routing/app/utils/resolveRoute.test.ts +47 -0
- package/layers/routing/app/utils/resolveRoute.ts +19 -10
- package/layers/scripts/app/composables/useAnalytics.ts +8 -41
- package/layers/scripts/app/composables/useGtm.ts +6 -13
- package/layers/scripts/app/utils/scriptClients.ts +70 -0
- package/layers/scroll/app/composables/useSmoothScroll.ts +9 -43
- package/layers/scroll/app/utils/scroll.ts +103 -0
- package/layers/seo/app/composables/useSeoConfig.ts +3 -9
- package/layers/seo/app/utils/seoConfig.ts +38 -0
- package/layers/shader/app/components/Material/AmbientAurora.client.vue +11 -33
- package/layers/shader/app/components/Material/AmbientFlow.client.vue +10 -37
- package/layers/shader/app/components/Material/AmbientGradientMesh.client.vue +10 -37
- package/layers/shader/app/components/Material/AmbientNebula.client.vue +12 -37
- package/layers/shader/app/components/Material/AmbientOcean.client.vue +9 -33
- package/layers/shader/app/components/Material/Gradient.client.vue +25 -46
- package/layers/shader/app/components/Material/Image.client.vue +10 -55
- package/layers/shader/app/components/Material/Node.client.vue +18 -5
- package/layers/shader/app/components/Material/Noise.client.vue +9 -43
- package/layers/shader/app/components/Preset/ThemeBubble.client.vue +2 -1
- package/layers/shader/app/components/Preset/ThemeFlow.client.vue +2 -1
- package/layers/shader/app/components/Preset/ThemeGradient.client.vue +2 -1
- package/layers/shader/app/components/Preset/ThemeLavaLamp.client.vue +2 -1
- package/layers/shader/app/components/Preset/ThemePlasma.client.vue +2 -1
- package/layers/shader/app/components/Preset/ThemeWave.client.vue +2 -1
- package/layers/shader/app/components/Shader/Background.client.vue +44 -24
- package/layers/shader/app/composables/useAmbientMaterials.ts +5 -1
- package/layers/shader/app/composables/useShader.ts +38 -23
- package/layers/shader/app/composables/useShaderGraph.ts +11 -6
- package/layers/shader/app/composables/useShaderMixBlend.ts +4 -4
- package/layers/shader/app/composables/useShaderRuntime.ts +0 -1
- package/layers/shader/app/composables/useShaderVec2.ts +2 -4
- package/layers/shader/app/composables/useThemePreset.ts +34 -8
- package/layers/shader/app/composables/useUniformWatchers.ts +15 -0
- package/layers/shader/app/composables/useUniforms.ts +0 -1
- package/layers/shader/app/shaders/common/blend.ts +4 -4
- package/layers/shader/app/shaders/common/effects.ts +38 -21
- package/layers/shader/app/shaders/common/grain.ts +46 -49
- package/layers/shader/app/shaders/common/lighting.ts +17 -15
- package/layers/shader/app/shaders/common/math.ts +2 -4
- package/layers/shader/app/shaders/common/nodes.ts +17 -0
- package/layers/shader/app/shaders/common/palette.ts +21 -11
- package/layers/shader/app/shaders/common/patterns.ts +25 -14
- package/layers/shader/app/shaders/common/shapes.ts +97 -88
- package/layers/shader/app/shaders/common/uv.ts +33 -34
- package/layers/shader/app/shaders/createMaterial.ts +92 -78
- package/layers/shader/app/shaders/layers/paperShading.ts +22 -10
- package/layers/shader/app/shaders/layers/shaderGradient.ts +46 -21
- package/layers/shader/app/utils/tsl/tween.ts +2 -4
- package/layers/shader/package.json +5 -1
- package/layers/theme/app/components/ThemePicker/Menu.vue +3 -25
- package/layers/theme/app/composables/useThemePreferenceModels.ts +39 -0
- package/layers/theme/server/plugins/theme-fouc.ts +1 -92
- package/layers/theme/server/utils/accent-css.ts +75 -0
- package/layers/typography/app/composables/typography.ts +3 -7
- package/layers/visual/app/composables/accent.ts +2 -9
- package/layers/visual/app/composables/gradient.ts +33 -46
- package/layers/visual/app/composables/picture.ts +2 -79
- package/layers/visual/app/utils/colorTokens.ts +23 -0
- package/layers/visual/app/utils/gradientStyle.ts +41 -0
- package/layers/visual/app/utils/responsiveSizes.ts +49 -0
- package/package.json +17 -5
- package/layers/feeds/server/routes/feed/discovery.get.ts +0 -29
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { H3Event } from 'h3'
|
|
4
|
+
|
|
5
|
+
const queryCollectionMock = vi.hoisted(() => vi.fn())
|
|
6
|
+
|
|
7
|
+
vi.mock('@nuxt/content/nitro', () => ({
|
|
8
|
+
queryCollection: queryCollectionMock,
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
import { getContentFeedItems } from './content-adapter'
|
|
12
|
+
|
|
13
|
+
describe('getContentFeedItems', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
queryCollectionMock.mockReset()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('filters drafts, sorts by date, and maps feed items', async () => {
|
|
19
|
+
queryCollectionMock.mockReturnValue({
|
|
20
|
+
all: vi.fn().mockResolvedValue([
|
|
21
|
+
{
|
|
22
|
+
draft: true,
|
|
23
|
+
title: 'Draft post',
|
|
24
|
+
path: '/draft',
|
|
25
|
+
date: '2026-01-04T00:00:00.000Z',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
title: 'Latest post',
|
|
29
|
+
description: 'Newest content',
|
|
30
|
+
path: '/latest',
|
|
31
|
+
date: '2026-01-03T00:00:00.000Z',
|
|
32
|
+
authors: [{ name: 'Ada Lovelace' }],
|
|
33
|
+
tags: ['nuxt'],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
stem: 'Older post',
|
|
37
|
+
_path: '/older',
|
|
38
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
39
|
+
author: { name: 'Grace Hopper' },
|
|
40
|
+
},
|
|
41
|
+
]),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const items = await getContentFeedItems({} as H3Event, 'articles', 2)
|
|
45
|
+
|
|
46
|
+
expect(queryCollectionMock).toHaveBeenCalledWith({}, 'articles')
|
|
47
|
+
expect(items).toEqual([
|
|
48
|
+
{
|
|
49
|
+
title: 'Latest post',
|
|
50
|
+
description: 'Newest content',
|
|
51
|
+
link: '/latest',
|
|
52
|
+
id: '/latest',
|
|
53
|
+
date: new Date('2026-01-03T00:00:00.000Z'),
|
|
54
|
+
author: 'Ada Lovelace',
|
|
55
|
+
tags: ['nuxt'],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
title: 'Older post',
|
|
59
|
+
description: undefined,
|
|
60
|
+
link: '/older',
|
|
61
|
+
id: '/older',
|
|
62
|
+
date: new Date('2026-01-01T00:00:00.000Z'),
|
|
63
|
+
author: 'Grace Hopper',
|
|
64
|
+
tags: undefined,
|
|
65
|
+
},
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -2,6 +2,7 @@ import { queryCollection } from '@nuxt/content/nitro'
|
|
|
2
2
|
import type { H3Event } from 'h3'
|
|
3
3
|
|
|
4
4
|
import type { FeedItem } from './types'
|
|
5
|
+
import { resolveFeedAuthor, resolveFeedDate } from './feed-author'
|
|
5
6
|
|
|
6
7
|
type FeedSourceAuthor = {
|
|
7
8
|
name?: string | undefined
|
|
@@ -22,7 +23,7 @@ type FeedSourceItem = {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
type FeedCollectionQuery = {
|
|
25
|
-
all()
|
|
26
|
+
all: () => Promise<FeedSourceItem[]>
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
const getFeedCollection = queryCollection as unknown as (
|
|
@@ -30,27 +31,6 @@ const getFeedCollection = queryCollection as unknown as (
|
|
|
30
31
|
collection: string
|
|
31
32
|
) => FeedCollectionQuery
|
|
32
33
|
|
|
33
|
-
function resolveFeedAuthor(item: FeedSourceItem): string | undefined {
|
|
34
|
-
const firstAuthor = item.authors?.[0]
|
|
35
|
-
if (typeof firstAuthor?.name === 'string' && firstAuthor.name.length > 0) {
|
|
36
|
-
return firstAuthor.name
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (typeof item.author === 'string' && item.author.length > 0) {
|
|
40
|
-
return item.author
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (item.author && typeof item.author === 'object' && typeof item.author.name === 'string') {
|
|
44
|
-
return item.author.name
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return undefined
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function resolveFeedDate(item: FeedSourceItem): Date {
|
|
51
|
-
return new Date(item.date ?? item.createdAt ?? Date.now())
|
|
52
|
-
}
|
|
53
|
-
|
|
54
34
|
export async function getContentFeedItems(
|
|
55
35
|
event: H3Event,
|
|
56
36
|
collection: string = 'blog',
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
type FeedSourceAuthor = {
|
|
2
|
+
name?: string | undefined
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
type FeedSourceItem = {
|
|
6
|
+
author?: FeedSourceAuthor | string | undefined
|
|
7
|
+
authors?: FeedSourceAuthor[] | undefined
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function resolveFeedAuthor(item: FeedSourceItem): string | undefined {
|
|
11
|
+
const firstAuthor = item.authors?.[0]
|
|
12
|
+
if (typeof firstAuthor?.name === 'string' && firstAuthor.name.length > 0) {
|
|
13
|
+
return firstAuthor.name
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (typeof item.author === 'string' && item.author.length > 0) {
|
|
17
|
+
return item.author
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (item.author && typeof item.author === 'object' && typeof item.author.name === 'string') {
|
|
21
|
+
return item.author.name
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveFeedDate(item: {
|
|
28
|
+
date?: string | number | Date | undefined
|
|
29
|
+
createdAt?: string | number | Date | undefined
|
|
30
|
+
}): Date {
|
|
31
|
+
return new Date(item.date ?? item.createdAt ?? Date.now())
|
|
32
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { SiteConfig } from '#layers/core/app/types/site'
|
|
2
|
+
|
|
3
|
+
import type { FeedConfig } from './types'
|
|
4
|
+
|
|
5
|
+
type AppFeedConfig = {
|
|
6
|
+
site?: SiteConfig
|
|
7
|
+
feedsLayer?: {
|
|
8
|
+
feed?: {
|
|
9
|
+
limit?: number
|
|
10
|
+
defaultCollection?: string
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type FeedLayerConfig = NonNullable<AppFeedConfig['feedsLayer']>['feed']
|
|
16
|
+
|
|
17
|
+
function resolveFeedTitle(site: SiteConfig, collection: string | undefined) {
|
|
18
|
+
const collectionLabel = collection
|
|
19
|
+
? ` — ${collection.charAt(0).toUpperCase() + collection.slice(1)}`
|
|
20
|
+
: ''
|
|
21
|
+
|
|
22
|
+
return `${site.title ?? 'My Site'}${collectionLabel}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveFeedAuthorName(author: SiteConfig['author']) {
|
|
26
|
+
const authorName = author?.name ?? ''
|
|
27
|
+
return authorName.length > 0 ? authorName : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveFeedAuthor(site: SiteConfig) {
|
|
31
|
+
const authorName = resolveFeedAuthorName(site.author)
|
|
32
|
+
return authorName
|
|
33
|
+
? { name: authorName, email: site.author?.email, link: site.author?.link }
|
|
34
|
+
: undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveFeedCopyright(site: SiteConfig) {
|
|
38
|
+
const authorName = resolveFeedAuthorName(site.author)
|
|
39
|
+
if (site.copyright) return site.copyright
|
|
40
|
+
if (!authorName) return undefined
|
|
41
|
+
return `Copyright ${new Date().getFullYear()} ${authorName}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveConfiguredFeedLimit(feedConfig: FeedLayerConfig) {
|
|
45
|
+
return feedConfig?.limit ?? 30
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveFeedSiteConfig(appConfig: AppFeedConfig, requestUrl: URL) {
|
|
49
|
+
const site = appConfig.site ?? {}
|
|
50
|
+
const origin = `${requestUrl.protocol}//${requestUrl.host}`
|
|
51
|
+
return {
|
|
52
|
+
site,
|
|
53
|
+
siteUrl: (site.url as string | undefined)?.replace(/\/$/, '') || origin,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveFeedCollection(
|
|
58
|
+
feedConfig: FeedLayerConfig,
|
|
59
|
+
collection?: string
|
|
60
|
+
) {
|
|
61
|
+
return collection ?? feedConfig?.defaultCollection ?? 'blog'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createFeedConfig(args: {
|
|
65
|
+
site: SiteConfig
|
|
66
|
+
siteUrl: string
|
|
67
|
+
collection: string | undefined
|
|
68
|
+
}): FeedConfig {
|
|
69
|
+
const { site, siteUrl, collection } = args
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
title: resolveFeedTitle(site, collection),
|
|
73
|
+
description: site.description ?? '',
|
|
74
|
+
siteUrl,
|
|
75
|
+
author: resolveFeedAuthor(site),
|
|
76
|
+
image: site.image || undefined,
|
|
77
|
+
favicon: site.favicon ?? '/favicon.ico',
|
|
78
|
+
copyright: resolveFeedCopyright(site),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resolveFeedLimit(
|
|
83
|
+
feedConfig: FeedLayerConfig,
|
|
84
|
+
options?: { unlimited?: boolean | undefined }
|
|
85
|
+
) {
|
|
86
|
+
if (options?.unlimited) return Infinity
|
|
87
|
+
return resolveConfiguredFeedLimit(feedConfig)
|
|
88
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import type { SiteConfig } from '#layers/core/app/types/site'
|
|
2
1
|
import type { H3Event } from 'h3'
|
|
3
2
|
|
|
4
3
|
import type { FeedConfig, FeedItem } from './types'
|
|
4
|
+
import {
|
|
5
|
+
createFeedConfig,
|
|
6
|
+
resolveFeedCollection,
|
|
7
|
+
resolveFeedLimit,
|
|
8
|
+
resolveFeedSiteConfig,
|
|
9
|
+
} from './feed-config'
|
|
5
10
|
|
|
6
11
|
export async function buildFeed(
|
|
7
12
|
event: H3Event,
|
|
@@ -9,37 +14,13 @@ export async function buildFeed(
|
|
|
9
14
|
options?: { unlimited?: boolean }
|
|
10
15
|
): Promise<{ items: FeedItem[]; config: FeedConfig }> {
|
|
11
16
|
const appConfig = useAppConfig()
|
|
12
|
-
const
|
|
13
|
-
const feedConfig =
|
|
14
|
-
(appConfig as { feedsLayer?: { feed?: { limit?: number; defaultCollection?: string } } })
|
|
15
|
-
.feedsLayer?.feed ?? {}
|
|
16
|
-
const limit: number = options?.unlimited ? Infinity : (feedConfig.limit ?? 30)
|
|
17
|
-
const defaultCollection = feedConfig.defaultCollection ?? 'blog'
|
|
18
|
-
|
|
17
|
+
const feedConfig = appConfig.feedsLayer?.feed
|
|
19
18
|
const requestUrl = getRequestURL(event)
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
const authorName = site.author?.name ?? ''
|
|
24
|
-
const resolvedCollection = collection ?? defaultCollection
|
|
25
|
-
const collectionLabel = collection
|
|
26
|
-
? ` — ${collection.charAt(0).toUpperCase() + collection.slice(1)}`
|
|
27
|
-
: ''
|
|
28
|
-
|
|
29
|
-
const config: FeedConfig = {
|
|
30
|
-
title: `${site.title ?? 'My Site'}${collectionLabel}`,
|
|
31
|
-
description: site.description ?? '',
|
|
32
|
-
siteUrl,
|
|
33
|
-
author: authorName
|
|
34
|
-
? { name: authorName, email: site.author?.email, link: site.author?.link }
|
|
35
|
-
: undefined,
|
|
36
|
-
image: site.image || undefined,
|
|
37
|
-
favicon: site.favicon ?? '/favicon.ico',
|
|
38
|
-
copyright:
|
|
39
|
-
site.copyright ||
|
|
40
|
-
(authorName ? `Copyright ${new Date().getFullYear()} ${authorName}` : undefined),
|
|
41
|
-
}
|
|
19
|
+
const { site, siteUrl } = resolveFeedSiteConfig(appConfig, requestUrl)
|
|
20
|
+
const resolvedCollection = resolveFeedCollection(feedConfig, collection)
|
|
21
|
+
const limit = resolveFeedLimit(feedConfig, options)
|
|
42
22
|
|
|
23
|
+
const config = createFeedConfig({ site, siteUrl, collection })
|
|
43
24
|
const items = await getContentFeedItems(event, resolvedCollection, limit)
|
|
44
25
|
|
|
45
26
|
return { items, config }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FeedConfig, FeedItem } from './types'
|
|
2
|
+
|
|
3
|
+
export function resolveFeedItemId(siteUrl: string, item: FeedItem) {
|
|
4
|
+
return `${siteUrl}${item.id}`
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function resolveFeedItemLink(siteUrl: string, item: FeedItem) {
|
|
8
|
+
return `${siteUrl}${item.link}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function applyFeedStylesheet(xml: string) {
|
|
12
|
+
return xml.replace(
|
|
13
|
+
/<\?xml version="1\.0" encoding="utf-8"\?>/i,
|
|
14
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="/feed/style.xsl"?>'
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveFeedAuthorPayload(config: FeedConfig['author']) {
|
|
19
|
+
return config
|
|
20
|
+
? {
|
|
21
|
+
name: config.name,
|
|
22
|
+
...(config.email ? { email: config.email } : {}),
|
|
23
|
+
...(config.link ? { link: config.link } : {}),
|
|
24
|
+
}
|
|
25
|
+
: undefined
|
|
26
|
+
}
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import { Feed } from 'feed'
|
|
2
2
|
|
|
3
3
|
import type { FeedConfig, FeedItem } from '../types'
|
|
4
|
+
import {
|
|
5
|
+
applyFeedStylesheet,
|
|
6
|
+
resolveFeedAuthorPayload,
|
|
7
|
+
resolveFeedItemId,
|
|
8
|
+
resolveFeedItemLink,
|
|
9
|
+
} from '../feed-xml'
|
|
4
10
|
|
|
5
11
|
export function toRSS(items: FeedItem[], config: FeedConfig): string {
|
|
6
|
-
const author = config.author
|
|
7
|
-
? {
|
|
8
|
-
name: config.author.name,
|
|
9
|
-
...(config.author.email ? { email: config.author.email } : {}),
|
|
10
|
-
...(config.author.link ? { link: config.author.link } : {}),
|
|
11
|
-
}
|
|
12
|
-
: undefined
|
|
13
|
-
|
|
12
|
+
const author = resolveFeedAuthorPayload(config.author)
|
|
14
13
|
const feed = new Feed({
|
|
15
14
|
title: config.title,
|
|
16
15
|
description: config.description,
|
|
@@ -26,17 +25,13 @@ export function toRSS(items: FeedItem[], config: FeedConfig): string {
|
|
|
26
25
|
for (const item of items) {
|
|
27
26
|
feed.addItem({
|
|
28
27
|
title: item.title,
|
|
29
|
-
id:
|
|
30
|
-
link:
|
|
28
|
+
id: resolveFeedItemId(config.siteUrl, item),
|
|
29
|
+
link: resolveFeedItemLink(config.siteUrl, item),
|
|
31
30
|
...(item.description ? { description: item.description } : {}),
|
|
32
31
|
date: item.date,
|
|
33
32
|
...(item.author ? { author: [{ name: item.author }] } : {}),
|
|
34
33
|
})
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
return raw.replace(
|
|
39
|
-
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
40
|
-
'<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="/feed/style.xsl"?>'
|
|
41
|
-
)
|
|
36
|
+
return applyFeedStylesheet(feed.rss2())
|
|
42
37
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { FeedConfig, FeedItem } from './types'
|
|
4
|
+
import { toAtom } from './formats/atom'
|
|
5
|
+
import { toJSONFeed } from './formats/json'
|
|
6
|
+
import { toRSS } from './formats/rss'
|
|
7
|
+
|
|
8
|
+
const config: FeedConfig = {
|
|
9
|
+
title: 'Nuxt Layers Feed',
|
|
10
|
+
description: 'Layered syndication output.',
|
|
11
|
+
siteUrl: 'https://example.com',
|
|
12
|
+
author: {
|
|
13
|
+
name: 'Kieran Mansfield',
|
|
14
|
+
link: 'https://example.com/about',
|
|
15
|
+
},
|
|
16
|
+
favicon: '/favicon.ico',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const items: FeedItem[] = [
|
|
20
|
+
{
|
|
21
|
+
title: 'A & B',
|
|
22
|
+
description: 'Read > write',
|
|
23
|
+
link: '/posts/a-b',
|
|
24
|
+
id: '/posts/a-b',
|
|
25
|
+
date: new Date('2026-01-03T10:00:00.000Z'),
|
|
26
|
+
author: 'Ada Lovelace',
|
|
27
|
+
tags: ['nuxt', 'feeds'],
|
|
28
|
+
},
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
describe('feed format serializers', () => {
|
|
32
|
+
it('serializes RSS output with the stylesheet and item data', () => {
|
|
33
|
+
const xml = toRSS(items, config)
|
|
34
|
+
|
|
35
|
+
expect(xml).toContain('<?xml-stylesheet type="text/xsl" href="/feed/style.xsl"?>')
|
|
36
|
+
expect(xml).toContain('<title>Nuxt Layers Feed</title>')
|
|
37
|
+
expect(xml).toContain('Ada Lovelace')
|
|
38
|
+
expect(xml).toContain('/posts/a-b')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('serializes JSON Feed output as a structured object', () => {
|
|
42
|
+
expect(toJSONFeed(items, config)).toMatchObject({
|
|
43
|
+
version: 'https://jsonfeed.org/version/1.1',
|
|
44
|
+
title: 'Nuxt Layers Feed',
|
|
45
|
+
description: 'Layered syndication output.',
|
|
46
|
+
home_page_url: 'https://example.com',
|
|
47
|
+
feed_url: 'https://example.com/feed/json',
|
|
48
|
+
authors: [{ name: 'Kieran Mansfield', url: 'https://example.com/about' }],
|
|
49
|
+
items: [
|
|
50
|
+
{
|
|
51
|
+
id: 'https://example.com/posts/a-b',
|
|
52
|
+
url: 'https://example.com/posts/a-b',
|
|
53
|
+
title: 'A & B',
|
|
54
|
+
content_text: 'Read > write',
|
|
55
|
+
date_published: '2026-01-03T10:00:00.000Z',
|
|
56
|
+
authors: [{ name: 'Ada Lovelace' }],
|
|
57
|
+
tags: ['nuxt', 'feeds'],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('serializes Atom output with escaped markup', () => {
|
|
64
|
+
const xml = toAtom(items, config)
|
|
65
|
+
|
|
66
|
+
expect(xml).toContain('<?xml-stylesheet type="text/xsl" href="/feed/style.xsl"?>')
|
|
67
|
+
expect(xml).toContain('<title>Nuxt Layers Feed</title>')
|
|
68
|
+
expect(xml).toContain('<title>A & B</title>')
|
|
69
|
+
expect(xml).toContain('<summary>Read > write</summary>')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { fieldConfigs, type FieldType } from '../../config/fields'
|
|
3
3
|
import type { FieldSize } from '../../types/fields'
|
|
4
|
+
import {
|
|
5
|
+
buildBaseInputProps,
|
|
6
|
+
buildFormFieldProps,
|
|
7
|
+
buildNumberInputProps,
|
|
8
|
+
buildTextInputProps,
|
|
9
|
+
} from '../../utils/fieldProps'
|
|
4
10
|
|
|
5
11
|
const {
|
|
6
12
|
type = 'text',
|
|
@@ -40,36 +46,6 @@
|
|
|
40
46
|
const isTextarea = computed(() => config.value.component === 'UTextarea')
|
|
41
47
|
const isNumber = computed(() => config.value.component === 'UInputNumber')
|
|
42
48
|
|
|
43
|
-
const formFieldProps = computed(() => ({
|
|
44
|
-
name,
|
|
45
|
-
required,
|
|
46
|
-
size,
|
|
47
|
-
...(label !== undefined && { label }),
|
|
48
|
-
...(className !== undefined && { class: className }),
|
|
49
|
-
}))
|
|
50
|
-
|
|
51
|
-
const baseInputProps = computed(() => ({
|
|
52
|
-
size,
|
|
53
|
-
...(resolvedPlaceholder.value !== undefined && { placeholder: resolvedPlaceholder.value }),
|
|
54
|
-
...(resolvedIcon.value !== undefined && { leadingIcon: resolvedIcon.value }),
|
|
55
|
-
}))
|
|
56
|
-
|
|
57
|
-
const numberInputProps = computed(() => ({
|
|
58
|
-
size,
|
|
59
|
-
...(resolvedPlaceholder.value !== undefined && { placeholder: resolvedPlaceholder.value }),
|
|
60
|
-
...(resolvedIcon.value !== undefined && { leadingIcon: resolvedIcon.value }),
|
|
61
|
-
...(currencyOptions.value !== undefined && { formatOptions: currencyOptions.value }),
|
|
62
|
-
}))
|
|
63
|
-
|
|
64
|
-
const textInputProps = computed(() => ({
|
|
65
|
-
type: config.value.inputType,
|
|
66
|
-
size,
|
|
67
|
-
...(config.value.inputMode !== undefined && { inputmode: config.value.inputMode }),
|
|
68
|
-
...(config.value.autocomplete !== undefined && { autocomplete: config.value.autocomplete }),
|
|
69
|
-
...(resolvedPlaceholder.value !== undefined && { placeholder: resolvedPlaceholder.value }),
|
|
70
|
-
...(resolvedIcon.value !== undefined && { leadingIcon: resolvedIcon.value }),
|
|
71
|
-
}))
|
|
72
|
-
|
|
73
49
|
const currencyOptions = computed((): Intl.NumberFormatOptions | undefined => {
|
|
74
50
|
if (config.value.format === 'currency') {
|
|
75
51
|
return {
|
|
@@ -79,6 +55,42 @@
|
|
|
79
55
|
}
|
|
80
56
|
return undefined
|
|
81
57
|
})
|
|
58
|
+
|
|
59
|
+
const formFieldProps = computed(() =>
|
|
60
|
+
buildFormFieldProps({
|
|
61
|
+
name,
|
|
62
|
+
required,
|
|
63
|
+
size,
|
|
64
|
+
label,
|
|
65
|
+
className,
|
|
66
|
+
})
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const baseInputProps = computed(() =>
|
|
70
|
+
buildBaseInputProps({
|
|
71
|
+
size,
|
|
72
|
+
placeholder: resolvedPlaceholder.value,
|
|
73
|
+
icon: resolvedIcon.value,
|
|
74
|
+
})
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const numberInputProps = computed(() =>
|
|
78
|
+
buildNumberInputProps({
|
|
79
|
+
size,
|
|
80
|
+
placeholder: resolvedPlaceholder.value,
|
|
81
|
+
icon: resolvedIcon.value,
|
|
82
|
+
formatOptions: currencyOptions.value,
|
|
83
|
+
})
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const textInputProps = computed(() =>
|
|
87
|
+
buildTextInputProps({
|
|
88
|
+
config: config.value,
|
|
89
|
+
size,
|
|
90
|
+
placeholder: resolvedPlaceholder.value,
|
|
91
|
+
icon: resolvedIcon.value,
|
|
92
|
+
})
|
|
93
|
+
)
|
|
82
94
|
</script>
|
|
83
95
|
|
|
84
96
|
<template>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { FieldConfig } from '../types/fields'
|
|
2
|
+
import type { FieldSize } from '../types/fields'
|
|
3
|
+
|
|
4
|
+
export function buildFormFieldProps(args: {
|
|
5
|
+
name: string
|
|
6
|
+
required: boolean
|
|
7
|
+
size: FieldSize
|
|
8
|
+
label?: string | undefined
|
|
9
|
+
className?: string | undefined
|
|
10
|
+
}) {
|
|
11
|
+
const { name, required, size, label, className } = args
|
|
12
|
+
return {
|
|
13
|
+
name,
|
|
14
|
+
required,
|
|
15
|
+
size,
|
|
16
|
+
...(label !== undefined && { label }),
|
|
17
|
+
...(className !== undefined && { class: className }),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildBaseInputProps(args: {
|
|
22
|
+
size: FieldSize
|
|
23
|
+
placeholder?: string | undefined
|
|
24
|
+
icon?: string | undefined
|
|
25
|
+
}) {
|
|
26
|
+
const { size, placeholder, icon } = args
|
|
27
|
+
return {
|
|
28
|
+
size,
|
|
29
|
+
...(placeholder !== undefined && { placeholder }),
|
|
30
|
+
...(icon !== undefined && { leadingIcon: icon }),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// fallow-ignore-next-line complexity
|
|
35
|
+
export function buildTextInputProps(args: {
|
|
36
|
+
config: FieldConfig
|
|
37
|
+
size: FieldSize
|
|
38
|
+
placeholder?: string | undefined
|
|
39
|
+
icon?: string | undefined
|
|
40
|
+
}) {
|
|
41
|
+
const { config, size, placeholder, icon } = args
|
|
42
|
+
return {
|
|
43
|
+
type: config.inputType,
|
|
44
|
+
size,
|
|
45
|
+
...(config.inputMode !== undefined && { inputmode: config.inputMode }),
|
|
46
|
+
...(config.autocomplete !== undefined && { autocomplete: config.autocomplete }),
|
|
47
|
+
...(placeholder !== undefined && { placeholder }),
|
|
48
|
+
...(icon !== undefined && { leadingIcon: icon }),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildNumberInputProps(args: {
|
|
53
|
+
size: FieldSize
|
|
54
|
+
placeholder?: string | undefined
|
|
55
|
+
icon?: string | undefined
|
|
56
|
+
formatOptions?: Intl.NumberFormatOptions | undefined
|
|
57
|
+
}) {
|
|
58
|
+
const { size, placeholder, icon, formatOptions } = args
|
|
59
|
+
return {
|
|
60
|
+
size,
|
|
61
|
+
...(placeholder !== undefined && { placeholder }),
|
|
62
|
+
...(icon !== undefined && { leadingIcon: icon }),
|
|
63
|
+
...(formatOptions !== undefined && { formatOptions }),
|
|
64
|
+
}
|
|
65
|
+
}
|