kmcom-nuxt-layers 6.3.0 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/docs/MIGRATION-v6.4.0.md +215 -0
  2. package/docs/MIGRATION-v6.5.0.md +87 -0
  3. package/docs/MIGRATION.md +3 -1
  4. package/docs/USAGE.md +35 -10
  5. package/docs/adr/0003-modular-step-system-replaces-fluid-clamps.md +16 -0
  6. package/docs/component-levels.md +1 -0
  7. package/docs/layout-shift-causes.md +18 -0
  8. package/layers/content/app/components/content/Figure.vue +1 -1
  9. package/layers/content/app/composables/useContentSection.ts +1 -1
  10. package/layers/core/app/app.config.ts +9 -5
  11. package/layers/core/app/app.vue +2 -1
  12. package/layers/core/app/components/LoadingScreen.vue +35 -21
  13. package/layers/core/app/composables/useCache.ts +2 -2
  14. package/layers/core/app/composables/useErrorLog.ts +5 -5
  15. package/layers/core/app/composables/useLoading.ts +19 -10
  16. package/layers/core/app/plugins/loading.client.ts +11 -3
  17. package/layers/core/app/types/loading.ts +10 -3
  18. package/layers/core/app/utils/loading.ts +7 -16
  19. package/layers/core/nuxt.config.ts +14 -14
  20. package/layers/core/server/utils/app-config.ts +17 -0
  21. package/layers/data/auth/app/middleware/auth.ts +1 -1
  22. package/layers/data/forms/app/components/Form/Contact.vue +1 -1
  23. package/layers/data/forms/server/api/contact.post.ts +16 -8
  24. package/layers/data/forms/server/api/forms/status.get.ts +3 -0
  25. package/layers/data/forms/{app → shared}/utils/contact-schema.ts +2 -1
  26. package/layers/data/metadata/server/api/metadata/search.get.ts +27 -7
  27. package/layers/data/metadata/server/api/metadata/status.get.ts +4 -1
  28. package/layers/data/metadata/server/api/metadata/sync.post.ts +13 -3
  29. package/layers/data/metadata/server/utils/metadata/resolve-record.ts +3 -3
  30. package/layers/delivery/feeds/nuxt.config.ts +6 -1
  31. package/layers/delivery/feeds/package.json +2 -2
  32. package/layers/delivery/feeds/server/routes/feed/demo.get.ts +2 -2
  33. package/layers/delivery/feeds/server/utils/content-adapter.ts +1 -1
  34. package/layers/delivery/feeds/server/utils/feed-service.ts +2 -2
  35. package/layers/design-system/theming/app/plugins/theme.client.ts +7 -0
  36. package/layers/design-system/theming/nuxt.config.ts +0 -2
  37. package/layers/design-system/typography/app/assets/css/typography.css +41 -5
  38. package/layers/design-system/visual/app/components/HoverDimGroup.vue +11 -3
  39. package/layers/motion/scroll/app/plugins/locomotive-scroll.client.ts +3 -2
  40. package/layers/render/canvas/nuxt.config.ts +0 -4
  41. package/layers/render/shader/nuxt.config.ts +0 -4
  42. package/layers/structure/navigation/app/app.config.ts +37 -14
  43. package/layers/structure/navigation/app/components/Mast/Nav.vue +14 -13
  44. package/layers/structure/navigation/app/components/Mast/NavModal.vue +83 -28
  45. package/layers/structure/navigation/app/components/Site/Logo.vue +35 -0
  46. package/layers/structure/navigation/app/components/Site/Title.vue +3 -3
  47. package/layers/structure/navigation/app/composables/mastNav.ts +38 -13
  48. package/layers/structure/navigation/app/types/nav.ts +34 -0
  49. package/layers/structure/routing/app/middleware/01.maintenance.global.ts +4 -1
  50. package/layers/structure/routing/app/middleware/02.governance.global.ts +1 -1
  51. package/layers/structure/routing/nuxt.config.ts +19 -12
  52. package/layers/structure/routing/server/api/feature-flags.get.ts +4 -2
  53. package/package.json +35 -5
  54. package/docs/FALLOW-COMPLEXITY-DUPLICATION-AUDIT.md +0 -64
  55. package/docs/IMPROVE-AUDIT-README.md +0 -29
  56. package/docs/IMPROVE-AUDIT-RESULTS.md +0 -51
  57. package/docs/IMPROVE-DEEP-AUDIT-RESULTS.md +0 -81
  58. package/docs/fallow-refactor/apps-debug.md +0 -27
  59. package/docs/fallow-refactor/apps-playground.md +0 -46
  60. package/docs/fallow-refactor/apps-visual-identity.md +0 -41
  61. package/docs/fallow-refactor/layers-animations.md +0 -34
  62. package/docs/fallow-refactor/layers-canvas.md +0 -32
  63. package/docs/fallow-refactor/layers-content.md +0 -33
  64. package/docs/fallow-refactor/layers-core.md +0 -39
  65. package/docs/fallow-refactor/layers-feeds.md +0 -39
  66. package/docs/fallow-refactor/layers-forms.md +0 -30
  67. package/docs/fallow-refactor/layers-layout.md +0 -42
  68. package/docs/fallow-refactor/layers-mailer.md +0 -32
  69. package/docs/fallow-refactor/layers-motion.md +0 -27
  70. package/docs/fallow-refactor/layers-navigation.md +0 -31
  71. package/docs/fallow-refactor/layers-page-transitions.md +0 -30
  72. package/docs/fallow-refactor/layers-routing.md +0 -33
  73. package/docs/fallow-refactor/layers-scripts.md +0 -35
  74. package/docs/fallow-refactor/layers-scroll.md +0 -38
  75. package/docs/fallow-refactor/layers-seo.md +0 -32
  76. package/docs/fallow-refactor/layers-shader.md +0 -53
  77. package/docs/fallow-refactor/layers-theme.md +0 -33
  78. package/docs/fallow-refactor/layers-transitions.md +0 -27
  79. package/docs/fallow-refactor/layers-typography.md +0 -29
  80. package/docs/fallow-refactor/layers-visual.md +0 -34
  81. package/docs/superpowers/plans/2026-09-03-layout-layer-fluid-rebuild.md +0 -1850
  82. package/docs/superpowers/plans/2026-09-03-vue-element-system.md +0 -1019
  83. package/docs/superpowers/plans/2026-09-04-grid-layer.md +0 -977
  84. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-0-1-design.md +0 -533
  85. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-2-design.md +0 -154
  86. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-3-design.md +0 -109
  87. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-4-design.md +0 -124
  88. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-5-design.md +0 -121
  89. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-6-design.md +0 -77
  90. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-7-design.md +0 -73
  91. package/docs/superpowers/specs/2026-08-31-swiss-grid-layer-design.md +0 -190
  92. package/docs/superpowers/specs/2026-09-03-layout-layer-fluid-rebuild-design.md +0 -312
  93. package/docs/superpowers/specs/2026-09-03-vue-element-system-design.md +0 -342
  94. package/docs/superpowers/specs/2026-09-04-grid-layer-design.md +0 -236
  95. package/layers/content/app/utils/galleryImageMeta.test.ts +0 -30
  96. package/layers/core/app/components/Element.test.ts +0 -90
  97. package/layers/core/app/components/ElementTw.test.ts +0 -64
  98. package/layers/core/app/composables/useLayoutAttrs.test.ts +0 -77
  99. package/layers/core/app/utils/browserInfo.test.ts +0 -81
  100. package/layers/core/app/utils/featureClasses.test.ts +0 -101
  101. package/layers/core/app/utils/loading.test.ts +0 -48
  102. package/layers/core/app/utils/resolveElementStyle.test.ts +0 -99
  103. package/layers/core/app/utils/scrollGuardClamp.test.ts +0 -36
  104. package/layers/core/app/utils/styleToTailwindClasses.test.ts +0 -31
  105. package/layers/data/forms/app/utils/contact-schema.test.ts +0 -75
  106. package/layers/data/forms/app/utils/fieldProps.test.ts +0 -141
  107. package/layers/data/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.test.ts +0 -201
  108. package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/client.test.ts +0 -76
  109. package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/normalise.test.ts +0 -144
  110. package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.test.ts +0 -25
  111. package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.test.ts +0 -144
  112. package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.test.ts +0 -29
  113. package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.test.ts +0 -162
  114. package/layers/data/metadata/server/utils/metadata/cache.test.ts +0 -39
  115. package/layers/data/metadata/server/utils/metadata/provider-fetch.test.ts +0 -27
  116. package/layers/data/metadata/server/utils/metadata/provider-registry.test.ts +0 -54
  117. package/layers/data/metadata/server/utils/metadata/resolve-record.test.ts +0 -87
  118. package/layers/data/metadata/server/utils/metadata/search.test.ts +0 -204
  119. package/layers/delivery/feeds/server/utils/content-adapter.test.ts +0 -67
  120. package/layers/delivery/feeds/server/utils/feed-author.test.ts +0 -71
  121. package/layers/delivery/feeds/server/utils/feed-config.test.ts +0 -184
  122. package/layers/delivery/feeds/server/utils/formats.test.ts +0 -71
  123. package/layers/delivery/seo/app/utils/seoConfig.test.ts +0 -58
  124. package/layers/design-system/theming/server/utils/accent-css.test.ts +0 -71
  125. package/layers/design-system/theming/server/utils/fouc-config.test.ts +0 -78
  126. package/layers/design-system/typography/app/components/Typography/Headline.test.ts +0 -36
  127. package/layers/design-system/typography/app/components/Typography/index.test.ts +0 -31
  128. package/layers/design-system/typography/app/composables/color.test.ts +0 -30
  129. package/layers/design-system/typography/app/composables/typography.test.ts +0 -53
  130. package/layers/design-system/visual/app/composables/tint.test.ts +0 -22
  131. package/layers/design-system/visual/app/utils/colorTokens.test.ts +0 -51
  132. package/layers/design-system/visual/app/utils/gradientStyle.test.ts +0 -75
  133. package/layers/design-system/visual/app/utils/responsiveSizes.test.ts +0 -71
  134. package/layers/motion/animations/app/components/Motion/Staggered.test.ts +0 -40
  135. package/layers/motion/scroll/app/composables/useReducedMotion.test.ts +0 -23
  136. package/layers/motion/scroll/app/utils/parallax.test.ts +0 -24
  137. package/layers/structure/grid/app/composables/useGridTracks.test.ts +0 -94
  138. package/layers/structure/grid/app/types/tracks.test.ts +0 -32
  139. package/layers/structure/grid/app/utils/placement.test.ts +0 -19
  140. package/layers/structure/layout/app/utils/gridPlacementStyle.test.ts +0 -269
  141. package/layers/structure/navigation/app/utils/site.test.ts +0 -39
  142. package/layers/structure/routing/app/utils/resolveRoute.test.ts +0 -47
@@ -1,204 +0,0 @@
1
- import type {
2
- MetadataProvider,
3
- MetadataSearchInput,
4
- MetadataSearchResult,
5
- } from '#layers/metadata/shared/types'
6
- import type * as CacheModule from './cache'
7
- import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
8
-
9
- // search.ts imports its sibling helpers directly. Keep the pure cache helpers real, and
10
- // control provider/cache lookups via mocks.
11
- const { providerState, getProvidersMock, getSearchCacheMock, setSearchCacheMock } = vi.hoisted(
12
- () => {
13
- const providerState: { mockProviders: MetadataProvider[] } = { mockProviders: [] }
14
- return {
15
- providerState,
16
- getProvidersMock: vi.fn(() => providerState.mockProviders),
17
- getSearchCacheMock: vi.fn(async () => null as MetadataSearchResult[] | null),
18
- setSearchCacheMock: vi.fn(async () => undefined),
19
- }
20
- }
21
- )
22
-
23
- vi.mock('./provider-registry', () => ({ getProviders: getProvidersMock }))
24
- vi.mock('./cache', async (importOriginal) => ({
25
- ...(await importOriginal<typeof CacheModule>()),
26
- getSearchCache: getSearchCacheMock,
27
- setSearchCache: setSearchCacheMock,
28
- }))
29
-
30
- const { searchMetadata } = await import('./search')
31
-
32
- function makeResult(overrides: Partial<MetadataSearchResult> = {}): MetadataSearchResult {
33
- return {
34
- id: 'comicvine:issue:1',
35
- provider: 'comicvine',
36
- providerId: '1',
37
- mediaType: 'comic',
38
- title: 'Watchmen',
39
- ...overrides,
40
- }
41
- }
42
-
43
- function makeProvider(overrides: Partial<MetadataProvider> = {}): MetadataProvider {
44
- return {
45
- id: 'comicvine',
46
- label: 'Comic Vine',
47
- mediaTypes: ['comic'],
48
- search: async () => [],
49
- lookup: async () => null,
50
- ...overrides,
51
- }
52
- }
53
-
54
- beforeEach(() => {
55
- providerState.mockProviders = []
56
- getProvidersMock.mockClear()
57
- getSearchCacheMock.mockClear()
58
- getSearchCacheMock.mockResolvedValue(null)
59
- setSearchCacheMock.mockClear()
60
- })
61
-
62
- function baseInput(overrides: Partial<MetadataSearchInput> = {}): MetadataSearchInput {
63
- return { query: 'watchmen', ...overrides }
64
- }
65
-
66
- describe('searchMetadata', () => {
67
- it('targets only providers named in input.providers', async () => {
68
- const a = vi.fn(async () => [makeResult({ id: 'a-1' })])
69
- const b = vi.fn(async () => [makeResult({ id: 'b-1' })])
70
- providerState.mockProviders = [
71
- makeProvider({ id: 'comicvine', search: a }),
72
- makeProvider({ id: 'openlibrary', mediaTypes: ['book'], search: b }),
73
- ]
74
-
75
- await searchMetadata(baseInput({ providers: ['comicvine'] }))
76
-
77
- expect(a).toHaveBeenCalledTimes(1)
78
- expect(b).not.toHaveBeenCalled()
79
- })
80
-
81
- it('targets providers by mediaType when providers[] is not given', async () => {
82
- const comic = vi.fn(async () => [makeResult()])
83
- const book = vi.fn(async () => [makeResult({ mediaType: 'book' })])
84
- providerState.mockProviders = [
85
- makeProvider({ id: 'comicvine', mediaTypes: ['comic'], search: comic }),
86
- makeProvider({ id: 'openlibrary', mediaTypes: ['book'], search: book }),
87
- ]
88
-
89
- await searchMetadata(baseInput({ mediaType: 'comic' }))
90
-
91
- expect(comic).toHaveBeenCalledTimes(1)
92
- expect(book).not.toHaveBeenCalled()
93
- })
94
-
95
- it('targets all providers when neither providers[] nor mediaType is given', async () => {
96
- const comic = vi.fn(async () => [makeResult()])
97
- const book = vi.fn(async () => [makeResult({ mediaType: 'book' })])
98
- providerState.mockProviders = [
99
- makeProvider({ id: 'comicvine', mediaTypes: ['comic'], search: comic }),
100
- makeProvider({ id: 'openlibrary', mediaTypes: ['book'], search: book }),
101
- ]
102
-
103
- await searchMetadata(baseInput())
104
-
105
- expect(comic).toHaveBeenCalledTimes(1)
106
- expect(book).toHaveBeenCalledTimes(1)
107
- })
108
-
109
- it('returns [] when no providers match the given targets', async () => {
110
- providerState.mockProviders = [makeProvider({ id: 'comicvine', mediaTypes: ['comic'] })]
111
-
112
- const result = await searchMetadata(baseInput({ mediaType: 'movie' }))
113
-
114
- expect(result).toEqual([])
115
- expect(getSearchCacheMock).not.toHaveBeenCalled()
116
- })
117
-
118
- it('returns [] when providers[] names an id with no registered provider', async () => {
119
- providerState.mockProviders = [makeProvider({ id: 'comicvine' })]
120
-
121
- const result = await searchMetadata(baseInput({ providers: ['tmdb'] }))
122
-
123
- expect(result).toEqual([])
124
- })
125
-
126
- it('keeps results from other providers when one provider rejects', async () => {
127
- const ok = vi.fn(async () => [makeResult({ id: 'ok-1', title: 'Watchmen' })])
128
- const failing = vi.fn(async () => {
129
- throw new Error('boom')
130
- })
131
- providerState.mockProviders = [
132
- makeProvider({ id: 'comicvine', search: ok }),
133
- makeProvider({ id: 'openlibrary', mediaTypes: ['book'], search: failing }),
134
- ]
135
-
136
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
137
-
138
- const result = await searchMetadata(baseInput())
139
-
140
- expect(result).toHaveLength(1)
141
- expect(result.at(0)?.id).toBe('ok-1')
142
- expect(consoleErrorSpy).toHaveBeenCalled()
143
-
144
- consoleErrorSpy.mockRestore()
145
- })
146
-
147
- it('deduplicates results by mediaType:title(lowercase):year', async () => {
148
- const a = vi.fn(async () => [
149
- makeResult({ id: 'a-1', title: 'Watchmen', mediaType: 'comic', publishedAt: '1986-09-01' }),
150
- ])
151
- const b = vi.fn(async () => [
152
- makeResult({ id: 'b-1', title: 'WATCHMEN', mediaType: 'comic', publishedAt: '1986-12-25' }),
153
- ])
154
- providerState.mockProviders = [
155
- makeProvider({ id: 'comicvine', search: a }),
156
- makeProvider({ id: 'openlibrary', mediaTypes: ['comic'], search: b }),
157
- ]
158
-
159
- const result = await searchMetadata(baseInput())
160
-
161
- expect(result).toHaveLength(1)
162
- expect(result.at(0)?.id).toBe('a-1')
163
- })
164
-
165
- it('does not dedupe results with different years', async () => {
166
- const a = vi.fn(async () => [
167
- makeResult({ id: 'a-1', title: 'Watchmen', publishedAt: '1986-09-01' }),
168
- ])
169
- const b = vi.fn(async () => [
170
- makeResult({ id: 'b-1', title: 'Watchmen', publishedAt: '2019-10-20' }),
171
- ])
172
- providerState.mockProviders = [
173
- makeProvider({ id: 'comicvine', search: a }),
174
- makeProvider({ id: 'openlibrary', mediaTypes: ['comic'], search: b }),
175
- ]
176
-
177
- const result = await searchMetadata(baseInput())
178
-
179
- expect(result).toHaveLength(2)
180
- })
181
-
182
- it('short-circuits on a cache hit without calling provider search functions', async () => {
183
- const cached = [makeResult({ id: 'cached-1' })]
184
- getSearchCacheMock.mockResolvedValue(cached)
185
- const search = vi.fn(async () => [makeResult({ id: 'live-1' })])
186
- providerState.mockProviders = [makeProvider({ id: 'comicvine', search })]
187
-
188
- const result = await searchMetadata(baseInput())
189
-
190
- expect(result).toBe(cached)
191
- expect(search).not.toHaveBeenCalled()
192
- expect(setSearchCacheMock).not.toHaveBeenCalled()
193
- })
194
-
195
- it('writes deduped results to the cache after a live search', async () => {
196
- const search = vi.fn(async () => [makeResult({ id: 'live-1' })])
197
- providerState.mockProviders = [makeProvider({ id: 'comicvine', search })]
198
-
199
- const result = await searchMetadata(baseInput())
200
-
201
- expect(setSearchCacheMock).toHaveBeenCalledTimes(1)
202
- expect(setSearchCacheMock).toHaveBeenCalledWith(expect.any(String), result)
203
- })
204
- })
@@ -1,67 +0,0 @@
1
- import type { H3Event } from 'h3'
2
- import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
3
-
4
- import { getContentFeedItems } from './content-adapter'
5
-
6
- const queryCollectionMock = vi.hoisted(() => vi.fn())
7
-
8
- vi.mock('@nuxt/content/nitro', () => ({
9
- queryCollection: queryCollectionMock,
10
- }))
11
-
12
- describe('getContentFeedItems', () => {
13
- beforeEach(() => {
14
- queryCollectionMock.mockReset()
15
- })
16
-
17
- it('filters drafts, sorts by date, and maps feed items', async () => {
18
- queryCollectionMock.mockReturnValue({
19
- all: vi.fn().mockResolvedValue([
20
- {
21
- draft: true,
22
- title: 'Draft post',
23
- path: '/draft',
24
- date: '2026-01-04T00:00:00.000Z',
25
- },
26
- {
27
- title: 'Latest post',
28
- description: 'Newest content',
29
- path: '/latest',
30
- date: '2026-01-03T00:00:00.000Z',
31
- authors: [{ name: 'Ada Lovelace' }],
32
- tags: ['nuxt'],
33
- },
34
- {
35
- stem: 'Older post',
36
- _path: '/older',
37
- createdAt: '2026-01-01T00:00:00.000Z',
38
- author: { name: 'Grace Hopper' },
39
- },
40
- ]),
41
- })
42
-
43
- const items = await getContentFeedItems({} as H3Event, 'articles', 2)
44
-
45
- expect(queryCollectionMock).toHaveBeenCalledWith({}, 'articles')
46
- expect(items).toEqual([
47
- {
48
- title: 'Latest post',
49
- description: 'Newest content',
50
- link: '/latest',
51
- id: '/latest',
52
- date: new Date('2026-01-03T00:00:00.000Z'),
53
- author: 'Ada Lovelace',
54
- tags: ['nuxt'],
55
- },
56
- {
57
- title: 'Older post',
58
- description: undefined,
59
- link: '/older',
60
- id: '/older',
61
- date: new Date('2026-01-01T00:00:00.000Z'),
62
- author: 'Grace Hopper',
63
- tags: undefined,
64
- },
65
- ])
66
- })
67
- })
@@ -1,71 +0,0 @@
1
- import { describe, expect, it } from 'vite-plus/test'
2
-
3
- import { resolveFeedAuthor, resolveFeedDate } from './feed-author'
4
-
5
- describe('resolveFeedAuthor', () => {
6
- it('prefers the first entry of the authors array', () => {
7
- expect(
8
- resolveFeedAuthor({
9
- authors: [{ name: 'Kieran Mansfield' }, { name: 'Someone Else' }],
10
- author: 'Fallback Author',
11
- })
12
- ).toBe('Kieran Mansfield')
13
- })
14
-
15
- it('falls back to a string author when authors array is missing', () => {
16
- expect(resolveFeedAuthor({ author: 'Kieran Mansfield' })).toBe('Kieran Mansfield')
17
- })
18
-
19
- it('falls back to an object author.name when author is not a string', () => {
20
- expect(resolveFeedAuthor({ author: { name: 'Kieran Mansfield' } })).toBe('Kieran Mansfield')
21
- })
22
-
23
- it('returns undefined when no author information is present', () => {
24
- expect(resolveFeedAuthor({})).toBeUndefined()
25
- })
26
-
27
- it('ignores an authors array entry with an empty name and falls through', () => {
28
- expect(resolveFeedAuthor({ authors: [{ name: '' }], author: 'Kieran Mansfield' })).toBe(
29
- 'Kieran Mansfield'
30
- )
31
- })
32
-
33
- it('ignores an empty string author and falls through to object author', () => {
34
- expect(resolveFeedAuthor({ author: '' })).toBeUndefined()
35
- })
36
-
37
- it('returns undefined when authors array is empty and no author is set', () => {
38
- expect(resolveFeedAuthor({ authors: [] })).toBeUndefined()
39
- })
40
- })
41
-
42
- describe('resolveFeedDate', () => {
43
- it('prefers item.date when present', () => {
44
- const result = resolveFeedDate({ date: '2024-01-01T00:00:00.000Z' })
45
- expect(result).toEqual(new Date('2024-01-01T00:00:00.000Z'))
46
- })
47
-
48
- it('falls back to item.createdAt when date is missing', () => {
49
- const result = resolveFeedDate({ createdAt: '2023-06-15T00:00:00.000Z' })
50
- expect(result).toEqual(new Date('2023-06-15T00:00:00.000Z'))
51
- })
52
-
53
- it('falls back to now when neither date nor createdAt is present', () => {
54
- const before = Date.now()
55
- const result = resolveFeedDate({})
56
- const after = Date.now()
57
-
58
- expect(result.getTime()).toBeGreaterThanOrEqual(before)
59
- expect(result.getTime()).toBeLessThanOrEqual(after)
60
- })
61
-
62
- it('accepts a numeric timestamp', () => {
63
- const timestamp = 1700000000000
64
- expect(resolveFeedDate({ date: timestamp })).toEqual(new Date(timestamp))
65
- })
66
-
67
- it('accepts a Date instance', () => {
68
- const date = new Date('2022-03-03T00:00:00.000Z')
69
- expect(resolveFeedDate({ date })).toEqual(date)
70
- })
71
- })
@@ -1,184 +0,0 @@
1
- import { describe, expect, it } from 'vite-plus/test'
2
-
3
- import {
4
- createFeedConfig,
5
- resolveFeedCollection,
6
- resolveFeedLimit,
7
- resolveFeedSiteConfig,
8
- } from './feed-config'
9
-
10
- describe('resolveFeedSiteConfig', () => {
11
- it('defaults site to an empty object when appConfig.site is missing', () => {
12
- const result = resolveFeedSiteConfig({}, new URL('https://example.com/feed'))
13
- expect(result.site).toEqual({})
14
- })
15
-
16
- it('strips a trailing slash from a configured site url', () => {
17
- const result = resolveFeedSiteConfig(
18
- { site: { url: 'https://example.com/' } },
19
- new URL('https://request-origin.com/feed')
20
- )
21
- expect(result.siteUrl).toBe('https://example.com')
22
- })
23
-
24
- it('keeps a site url without a trailing slash unchanged', () => {
25
- const result = resolveFeedSiteConfig(
26
- { site: { url: 'https://example.com' } },
27
- new URL('https://request-origin.com/feed')
28
- )
29
- expect(result.siteUrl).toBe('https://example.com')
30
- })
31
-
32
- it('falls back to the request origin when no site url is configured', () => {
33
- const result = resolveFeedSiteConfig({}, new URL('https://request-origin.com/feed?x=1'))
34
- expect(result.siteUrl).toBe('https://request-origin.com')
35
- })
36
- })
37
-
38
- describe('resolveFeedCollection', () => {
39
- it('uses the explicit collection argument when provided', () => {
40
- expect(resolveFeedCollection({ defaultCollection: 'docs' }, 'blog')).toBe('blog')
41
- })
42
-
43
- it('falls back to the configured default collection', () => {
44
- expect(resolveFeedCollection({ defaultCollection: 'docs' }, undefined)).toBe('docs')
45
- })
46
-
47
- it('falls back to "blog" when neither is provided', () => {
48
- expect(resolveFeedCollection(undefined, undefined)).toBe('blog')
49
- })
50
- })
51
-
52
- describe('createFeedConfig', () => {
53
- it('uses "My Site" as the title fallback with no collection label', () => {
54
- const config = createFeedConfig({
55
- site: {},
56
- siteUrl: 'https://example.com',
57
- collection: undefined,
58
- })
59
- expect(config.title).toBe('My Site')
60
- })
61
-
62
- it('appends a capitalized collection label to the title', () => {
63
- const config = createFeedConfig({
64
- site: { title: 'Nuxt Layers' },
65
- siteUrl: 'https://example.com',
66
- collection: 'blog',
67
- })
68
- expect(config.title).toBe('Nuxt Layers — Blog')
69
- })
70
-
71
- it('defaults description to an empty string', () => {
72
- const config = createFeedConfig({
73
- site: {},
74
- siteUrl: 'https://example.com',
75
- collection: undefined,
76
- })
77
- expect(config.description).toBe('')
78
- })
79
-
80
- it('passes description through when set', () => {
81
- const config = createFeedConfig({
82
- site: { description: 'A monorepo of Nuxt layers' },
83
- siteUrl: 'https://example.com',
84
- collection: undefined,
85
- })
86
- expect(config.description).toBe('A monorepo of Nuxt layers')
87
- })
88
-
89
- it('builds an author object when the site has a named author', () => {
90
- const config = createFeedConfig({
91
- site: {
92
- author: { name: 'Kieran Mansfield', email: 'hi@example.com', link: 'https://example.com' },
93
- },
94
- siteUrl: 'https://example.com',
95
- collection: undefined,
96
- })
97
- expect(config.author).toEqual({
98
- name: 'Kieran Mansfield',
99
- email: 'hi@example.com',
100
- link: 'https://example.com',
101
- })
102
- })
103
-
104
- it('omits author when the site author has no name', () => {
105
- const config = createFeedConfig({
106
- site: {},
107
- siteUrl: 'https://example.com',
108
- collection: undefined,
109
- })
110
- expect(config.author).toBeUndefined()
111
- })
112
-
113
- it('maps a falsy site.image to undefined', () => {
114
- const config = createFeedConfig({
115
- site: { image: '' },
116
- siteUrl: 'https://example.com',
117
- collection: undefined,
118
- })
119
- expect(config.image).toBeUndefined()
120
- })
121
-
122
- it('passes through a truthy site.image', () => {
123
- const config = createFeedConfig({
124
- site: { image: 'https://example.com/og.png' },
125
- siteUrl: 'https://example.com',
126
- collection: undefined,
127
- })
128
- expect(config.image).toBe('https://example.com/og.png')
129
- })
130
-
131
- it('defaults favicon to /favicon.ico', () => {
132
- const config = createFeedConfig({
133
- site: {},
134
- siteUrl: 'https://example.com',
135
- collection: undefined,
136
- })
137
- expect(config.favicon).toBe('/favicon.ico')
138
- })
139
-
140
- it('prefers an explicit copyright over a generated one', () => {
141
- const config = createFeedConfig({
142
- site: { copyright: 'All rights reserved', author: { name: 'Kieran Mansfield' } },
143
- siteUrl: 'https://example.com',
144
- collection: undefined,
145
- })
146
- expect(config.copyright).toBe('All rights reserved')
147
- })
148
-
149
- it('generates a copyright string from the author name and current year', () => {
150
- const config = createFeedConfig({
151
- site: { author: { name: 'Kieran Mansfield' } },
152
- siteUrl: 'https://example.com',
153
- collection: undefined,
154
- })
155
- expect(config.copyright).toBe(`Copyright ${new Date().getFullYear()} Kieran Mansfield`)
156
- })
157
-
158
- it('omits copyright when there is no explicit copyright and no author name', () => {
159
- const config = createFeedConfig({
160
- site: {},
161
- siteUrl: 'https://example.com',
162
- collection: undefined,
163
- })
164
- expect(config.copyright).toBeUndefined()
165
- })
166
- })
167
-
168
- describe('resolveFeedLimit', () => {
169
- it('defaults to 30 when no limit is configured', () => {
170
- expect(resolveFeedLimit(undefined)).toBe(30)
171
- })
172
-
173
- it('uses the configured limit', () => {
174
- expect(resolveFeedLimit({ limit: 10 })).toBe(10)
175
- })
176
-
177
- it('returns Infinity when unlimited is requested, ignoring the configured limit', () => {
178
- expect(resolveFeedLimit({ limit: 10 }, { unlimited: true })).toBe(Infinity)
179
- })
180
-
181
- it('returns the default limit when unlimited is explicitly false', () => {
182
- expect(resolveFeedLimit(undefined, { unlimited: false })).toBe(30)
183
- })
184
- })
@@ -1,71 +0,0 @@
1
- import { describe, expect, it } from 'vite-plus/test'
2
-
3
- import { toAtom } from './formats/atom'
4
- import { toJSONFeed } from './formats/json'
5
- import { toRSS } from './formats/rss'
6
- import type { FeedConfig, FeedItem } from './types'
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 &amp; B</title>')
69
- expect(xml).toContain('<summary>Read &gt; write</summary>')
70
- })
71
- })
@@ -1,58 +0,0 @@
1
- import type { SiteConfig } from '#layers/core/app/types/site'
2
- import { describe, expect, it } from 'vite-plus/test'
3
-
4
- import { resolveSeoConfig } from './seoConfig'
5
-
6
- const site: SiteConfig = { title: 'Nuxt Layers', description: 'A composable layer system' }
7
-
8
- describe('resolveSeoConfig', () => {
9
- it('applies all defaults when no seoLayer config is given', () => {
10
- expect(resolveSeoConfig(site)).toEqual({
11
- site,
12
- ogImageComponent: 'OgImageNuxtSeo',
13
- ogImageProps: {},
14
- twitterCard: 'summary_large_image',
15
- schemaOrgEnabled: true,
16
- identity: null,
17
- })
18
- })
19
-
20
- it('applies all defaults when seoLayer is an empty object', () => {
21
- expect(resolveSeoConfig(site, {})).toEqual({
22
- site,
23
- ogImageComponent: 'OgImageNuxtSeo',
24
- ogImageProps: {},
25
- twitterCard: 'summary_large_image',
26
- schemaOrgEnabled: true,
27
- identity: null,
28
- })
29
- })
30
-
31
- it('uses the configured og:image component and props when provided', () => {
32
- const result = resolveSeoConfig(site, {
33
- ogImage: { component: 'OgImageCustom', props: { title: 'Custom' } },
34
- })
35
-
36
- expect(result.ogImageComponent).toBe('OgImageCustom')
37
- expect(result.ogImageProps).toEqual({ title: 'Custom' })
38
- })
39
-
40
- it('uses the configured twitter card type', () => {
41
- expect(resolveSeoConfig(site, { twitterCard: 'summary' }).twitterCard).toBe('summary')
42
- })
43
-
44
- it('allows schemaOrg to be disabled explicitly', () => {
45
- const result = resolveSeoConfig(site, { schemaOrg: { enabled: false } })
46
- expect(result.schemaOrgEnabled).toBe(false)
47
- })
48
-
49
- it('passes through a configured schemaOrg identity', () => {
50
- const identity = { type: 'Organization', name: 'Nuxt Layers' }
51
- const result = resolveSeoConfig(site, { schemaOrg: { identity } })
52
- expect(result.identity).toBe(identity)
53
- })
54
-
55
- it('preserves the site object reference in the result', () => {
56
- expect(resolveSeoConfig(site).site).toBe(site)
57
- })
58
- })