kmcom-nuxt-layers 2.5.1 → 2.6.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 (58) hide show
  1. package/layers/core/app/utils/browserInfo.test.ts +81 -0
  2. package/layers/core/app/utils/featureClasses.test.ts +103 -0
  3. package/layers/core/app/utils/regex.test.ts +31 -0
  4. package/layers/feeds/nuxt.config.ts +7 -10
  5. package/layers/feeds/server/utils/feed-author.test.ts +71 -0
  6. package/layers/feeds/server/utils/feed-config.test.ts +162 -0
  7. package/layers/forms/app/utils/contact-schema.test.ts +75 -0
  8. package/layers/forms/app/utils/fieldProps.test.ts +141 -0
  9. package/layers/layout/app/utils/gridPlacementStyle.test.ts +310 -0
  10. package/layers/metadata/providers/comicvine/nuxt.config.ts +1 -1
  11. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/client.ts +13 -6
  12. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.test.ts +201 -0
  13. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.ts +34 -15
  14. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/provider.ts +26 -9
  15. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/types.ts +1 -0
  16. package/layers/metadata/providers/comicvine/tsconfig.json +1 -1
  17. package/layers/metadata/providers/google-books/nuxt.config.ts +1 -1
  18. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/client.test.ts +76 -0
  19. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/client.ts +20 -2
  20. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/normalise.test.ts +144 -0
  21. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/normalise.ts +21 -12
  22. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/provider.ts +11 -4
  23. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/types.ts +9 -1
  24. package/layers/metadata/providers/google-books/tsconfig.json +1 -1
  25. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.test.ts +25 -0
  26. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.ts +14 -6
  27. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.test.ts +142 -0
  28. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.ts +30 -24
  29. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/provider.ts +5 -1
  30. package/layers/metadata/providers/openlibrary/tsconfig.json +1 -1
  31. package/layers/metadata/providers/themoviedb/nuxt.config.ts +36 -0
  32. package/layers/metadata/providers/themoviedb/package.json +12 -0
  33. package/layers/metadata/providers/themoviedb/server/plugins/tmdb-register.ts +3 -0
  34. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.test.ts +31 -0
  35. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.ts +60 -0
  36. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.test.ts +162 -0
  37. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.ts +83 -0
  38. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/provider.ts +37 -0
  39. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/types.ts +81 -0
  40. package/layers/metadata/providers/themoviedb/tsconfig.json +5 -0
  41. package/layers/metadata/server/api/metadata/lookup.get.ts +7 -2
  42. package/layers/metadata/server/api/metadata/search.get.ts +7 -2
  43. package/layers/metadata/server/api/metadata/status.get.ts +24 -0
  44. package/layers/metadata/server/api/metadata/sync.post.ts +14 -4
  45. package/layers/metadata/server/utils/metadata/cache.test.ts +39 -0
  46. package/layers/metadata/server/utils/metadata/cache.ts +15 -3
  47. package/layers/metadata/server/utils/metadata/errors.ts +1 -1
  48. package/layers/metadata/server/utils/metadata/provider-registry.test.ts +54 -0
  49. package/layers/metadata/server/utils/metadata/search.test.ts +198 -0
  50. package/layers/metadata/server/utils/metadata/search.ts +9 -4
  51. package/layers/navigation/app/utils/site.test.ts +41 -0
  52. package/layers/seo/app/utils/seoConfig.test.ts +58 -0
  53. package/layers/theme/server/utils/accent-css.test.ts +56 -0
  54. package/layers/theme/server/utils/fouc-config.test.ts +74 -0
  55. package/layers/visual/app/utils/colorTokens.test.ts +51 -0
  56. package/layers/visual/app/utils/gradientStyle.test.ts +76 -0
  57. package/layers/visual/app/utils/responsiveSizes.test.ts +71 -0
  58. package/package.json +6 -2
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { getBrowserVersionParts, isBrowserAtLeast, parseBrowserInfo } from './browserInfo'
4
+
5
+ describe('parseBrowserInfo', () => {
6
+ // In a node vitest environment `import.meta.client` is not defined by Nuxt's
7
+ // build macros, so it is falsy and the function always takes its SSR/default
8
+ // branch regardless of the user agent passed in.
9
+ it('returns the unknown/default shape when import.meta.client is falsy', () => {
10
+ expect(parseBrowserInfo('Mozilla/5.0 Chrome/120.0.0.0')).toEqual({
11
+ name: 'unknown',
12
+ version: '0',
13
+ engine: 'unknown',
14
+ os: 'unknown',
15
+ })
16
+ })
17
+
18
+ it('returns the same default shape with no user agent argument', () => {
19
+ expect(parseBrowserInfo()).toEqual({
20
+ name: 'unknown',
21
+ version: '0',
22
+ engine: 'unknown',
23
+ os: 'unknown',
24
+ })
25
+ })
26
+
27
+ it('returns the same default shape with a null user agent', () => {
28
+ expect(parseBrowserInfo(null)).toEqual({
29
+ name: 'unknown',
30
+ version: '0',
31
+ engine: 'unknown',
32
+ os: 'unknown',
33
+ })
34
+ })
35
+ })
36
+
37
+ describe('getBrowserVersionParts', () => {
38
+ it('parses a major.minor version string', () => {
39
+ expect(getBrowserVersionParts('120.5')).toEqual({ major: 120, minor: 5 })
40
+ })
41
+
42
+ it('defaults minor to 0 when only a major version is given', () => {
43
+ expect(getBrowserVersionParts('120')).toEqual({ major: 120, minor: 0 })
44
+ })
45
+
46
+ it('defaults both parts to 0 for a non-numeric version', () => {
47
+ expect(getBrowserVersionParts('abc')).toEqual({ major: 0, minor: 0 })
48
+ })
49
+
50
+ it('defaults both parts to 0 for an empty string', () => {
51
+ expect(getBrowserVersionParts('')).toEqual({ major: 0, minor: 0 })
52
+ })
53
+
54
+ it('parses extra version segments, ignoring anything past minor', () => {
55
+ expect(getBrowserVersionParts('120.5.9.1')).toEqual({ major: 120, minor: 5 })
56
+ })
57
+ })
58
+
59
+ describe('isBrowserAtLeast', () => {
60
+ it('returns false when the minimum version has no major segment', () => {
61
+ expect(isBrowserAtLeast('120.0', '')).toBe(false)
62
+ })
63
+
64
+ it('returns true when the current major exceeds the required major', () => {
65
+ expect(isBrowserAtLeast('121.0', '120.5')).toBe(true)
66
+ })
67
+
68
+ it('returns false when the current major is behind the required major', () => {
69
+ expect(isBrowserAtLeast('119.9', '120.0')).toBe(false)
70
+ })
71
+
72
+ it('compares minor versions when majors are equal', () => {
73
+ expect(isBrowserAtLeast('120.5', '120.5')).toBe(true)
74
+ expect(isBrowserAtLeast('120.6', '120.5')).toBe(true)
75
+ expect(isBrowserAtLeast('120.4', '120.5')).toBe(false)
76
+ })
77
+
78
+ it('treats a missing minimum minor as 0', () => {
79
+ expect(isBrowserAtLeast('120.0', '120')).toBe(true)
80
+ })
81
+ })
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { FeatureDetection } from '../types/detection'
4
+ import { getFeatureClassNames } from './featureClasses'
5
+
6
+ function createFeatures(overrides: Partial<FeatureDetection> = {}): FeatureDetection {
7
+ return {
8
+ grid: false,
9
+ subgrid: false,
10
+ containerQueries: false,
11
+ has: false,
12
+ aspectRatio: false,
13
+ logicalProperties: false,
14
+ backdropFilter: false,
15
+ intersectionObserver: false,
16
+ resizeObserver: false,
17
+ serviceWorker: false,
18
+ webGL: false,
19
+ darkMode: false,
20
+ reducedMotion: false,
21
+ highContrast: false,
22
+ webp: false,
23
+ avif: false,
24
+ ...overrides,
25
+ }
26
+ }
27
+
28
+ describe('getFeatureClassNames', () => {
29
+ it('emits the "no-" variant for every unsupported CSS feature and no extras', () => {
30
+ expect(getFeatureClassNames(createFeatures())).toEqual([
31
+ 'no-grid',
32
+ 'no-subgrid',
33
+ 'no-container-queries',
34
+ 'no-has',
35
+ 'no-aspect-ratio',
36
+ 'no-backdrop-filter',
37
+ ])
38
+ })
39
+
40
+ it('emits the "supports-" variant for every supported CSS feature plus all extras', () => {
41
+ expect(
42
+ getFeatureClassNames(
43
+ createFeatures({
44
+ grid: true,
45
+ subgrid: true,
46
+ containerQueries: true,
47
+ has: true,
48
+ aspectRatio: true,
49
+ backdropFilter: true,
50
+ intersectionObserver: true,
51
+ resizeObserver: true,
52
+ serviceWorker: true,
53
+ webGL: true,
54
+ webp: true,
55
+ avif: true,
56
+ })
57
+ )
58
+ ).toEqual([
59
+ 'supports-grid',
60
+ 'supports-subgrid',
61
+ 'supports-container-queries',
62
+ 'supports-has',
63
+ 'supports-aspect-ratio',
64
+ 'supports-backdrop-filter',
65
+ 'has-intersection-observer',
66
+ 'has-resize-observer',
67
+ 'has-service-worker',
68
+ 'has-webgl',
69
+ 'supports-webp',
70
+ 'supports-avif',
71
+ ])
72
+ })
73
+
74
+ it('only includes extra classes for the features that are truthy', () => {
75
+ const classes = getFeatureClassNames(
76
+ createFeatures({ intersectionObserver: true, avif: true })
77
+ )
78
+
79
+ expect(classes).toContain('has-intersection-observer')
80
+ expect(classes).toContain('supports-avif')
81
+ expect(classes).not.toContain('has-resize-observer')
82
+ expect(classes).not.toContain('supports-webp')
83
+ })
84
+
85
+ it('mixes supported and unsupported CSS features independently', () => {
86
+ const classes = getFeatureClassNames(createFeatures({ grid: true, has: true }))
87
+
88
+ expect(classes).toEqual([
89
+ 'supports-grid',
90
+ 'no-subgrid',
91
+ 'no-container-queries',
92
+ 'supports-has',
93
+ 'no-aspect-ratio',
94
+ 'no-backdrop-filter',
95
+ ])
96
+ })
97
+
98
+ it('ignores keys that are not part of either class map (e.g. darkMode)', () => {
99
+ const classes = getFeatureClassNames(createFeatures({ darkMode: true, highContrast: true }))
100
+ expect(classes.join(' ')).not.toContain('dark')
101
+ expect(classes.join(' ')).not.toContain('contrast')
102
+ })
103
+ })
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { splitSpaces } from './regex'
4
+
5
+ describe('splitSpaces', () => {
6
+ it('splits a sentence on single whitespace characters', () => {
7
+ expect(splitSpaces('Nuxt Layers rocks')).toEqual(['Nuxt', 'Layers', 'rocks'])
8
+ })
9
+
10
+ it('splits on the first whitespace match only per gap (non-global regex)', () => {
11
+ // The source regex has no /g flag, so String#split still splits on every
12
+ // occurrence (split applies the pattern repeatedly regardless of /g).
13
+ expect(splitSpaces('a b')).toEqual(['a', '', 'b'])
14
+ })
15
+
16
+ it('returns a single-element array for text with no spaces', () => {
17
+ expect(splitSpaces('single')).toEqual(['single'])
18
+ })
19
+
20
+ it('returns two empty strings for a single space', () => {
21
+ expect(splitSpaces(' ')).toEqual(['', ''])
22
+ })
23
+
24
+ it('returns an array with one empty string for an empty string', () => {
25
+ expect(splitSpaces('')).toEqual([''])
26
+ })
27
+
28
+ it('splits on tabs and newlines as whitespace', () => {
29
+ expect(splitSpaces('a\tb\nc')).toEqual(['a', 'b', 'c'])
30
+ })
31
+ })
@@ -18,16 +18,13 @@ export default defineNuxtConfig({
18
18
  // These are owned by the feeds layer so we prerender them here.
19
19
  // Collection-specific routes (/feed/blog/rss etc.) must be added to
20
20
  // nitro.prerender.routes in the consuming app's nuxt.config.ts.
21
- routes: [
22
- '/feed/demo',
23
- '/feed/rss',
24
- '/feed/atom',
25
- '/feed/json',
26
- '/feed/rss/all',
27
- '/feed/atom/all',
28
- '/feed/json/all',
29
- '/feed/style.xsl',
30
- ],
21
+ //
22
+ // The /all variants (/feed/rss/all etc.) are intentionally NOT prerendered:
23
+ // prerendering /feed/rss to a static file `feed/rss` and /feed/rss/all to
24
+ // `feed/rss/all` collides on disk (a file and a directory can't share the
25
+ // same path → EISDIR/EEXIST). The /all routes are served dynamically by the
26
+ // Nitro server function instead.
27
+ routes: ['/feed/demo', '/feed/rss', '/feed/atom', '/feed/json', '/feed/style.xsl'],
31
28
  },
32
29
  },
33
30
 
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest'
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(
29
+ resolveFeedAuthor({ authors: [{ name: '' }], author: 'Kieran Mansfield' })
30
+ ).toBe('Kieran Mansfield')
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
+ })
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it } from 'vitest'
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({ site: {}, siteUrl: 'https://example.com', collection: undefined })
55
+ expect(config.title).toBe('My Site')
56
+ })
57
+
58
+ it('appends a capitalized collection label to the title', () => {
59
+ const config = createFeedConfig({
60
+ site: { title: 'Nuxt Layers' },
61
+ siteUrl: 'https://example.com',
62
+ collection: 'blog',
63
+ })
64
+ expect(config.title).toBe('Nuxt Layers — Blog')
65
+ })
66
+
67
+ it('defaults description to an empty string', () => {
68
+ const config = createFeedConfig({ site: {}, siteUrl: 'https://example.com', collection: undefined })
69
+ expect(config.description).toBe('')
70
+ })
71
+
72
+ it('passes description through when set', () => {
73
+ const config = createFeedConfig({
74
+ site: { description: 'A monorepo of Nuxt layers' },
75
+ siteUrl: 'https://example.com',
76
+ collection: undefined,
77
+ })
78
+ expect(config.description).toBe('A monorepo of Nuxt layers')
79
+ })
80
+
81
+ it('builds an author object when the site has a named author', () => {
82
+ const config = createFeedConfig({
83
+ site: { author: { name: 'Kieran Mansfield', email: 'hi@example.com', link: 'https://example.com' } },
84
+ siteUrl: 'https://example.com',
85
+ collection: undefined,
86
+ })
87
+ expect(config.author).toEqual({
88
+ name: 'Kieran Mansfield',
89
+ email: 'hi@example.com',
90
+ link: 'https://example.com',
91
+ })
92
+ })
93
+
94
+ it('omits author when the site author has no name', () => {
95
+ const config = createFeedConfig({ site: {}, siteUrl: 'https://example.com', collection: undefined })
96
+ expect(config.author).toBeUndefined()
97
+ })
98
+
99
+ it('maps a falsy site.image to undefined', () => {
100
+ const config = createFeedConfig({
101
+ site: { image: '' },
102
+ siteUrl: 'https://example.com',
103
+ collection: undefined,
104
+ })
105
+ expect(config.image).toBeUndefined()
106
+ })
107
+
108
+ it('passes through a truthy site.image', () => {
109
+ const config = createFeedConfig({
110
+ site: { image: 'https://example.com/og.png' },
111
+ siteUrl: 'https://example.com',
112
+ collection: undefined,
113
+ })
114
+ expect(config.image).toBe('https://example.com/og.png')
115
+ })
116
+
117
+ it('defaults favicon to /favicon.ico', () => {
118
+ const config = createFeedConfig({ site: {}, siteUrl: 'https://example.com', collection: undefined })
119
+ expect(config.favicon).toBe('/favicon.ico')
120
+ })
121
+
122
+ it('prefers an explicit copyright over a generated one', () => {
123
+ const config = createFeedConfig({
124
+ site: { copyright: 'All rights reserved', author: { name: 'Kieran Mansfield' } },
125
+ siteUrl: 'https://example.com',
126
+ collection: undefined,
127
+ })
128
+ expect(config.copyright).toBe('All rights reserved')
129
+ })
130
+
131
+ it('generates a copyright string from the author name and current year', () => {
132
+ const config = createFeedConfig({
133
+ site: { author: { name: 'Kieran Mansfield' } },
134
+ siteUrl: 'https://example.com',
135
+ collection: undefined,
136
+ })
137
+ expect(config.copyright).toBe(`Copyright ${new Date().getFullYear()} Kieran Mansfield`)
138
+ })
139
+
140
+ it('omits copyright when there is no explicit copyright and no author name', () => {
141
+ const config = createFeedConfig({ site: {}, siteUrl: 'https://example.com', collection: undefined })
142
+ expect(config.copyright).toBeUndefined()
143
+ })
144
+ })
145
+
146
+ describe('resolveFeedLimit', () => {
147
+ it('defaults to 30 when no limit is configured', () => {
148
+ expect(resolveFeedLimit(undefined)).toBe(30)
149
+ })
150
+
151
+ it('uses the configured limit', () => {
152
+ expect(resolveFeedLimit({ limit: 10 })).toBe(10)
153
+ })
154
+
155
+ it('returns Infinity when unlimited is requested, ignoring the configured limit', () => {
156
+ expect(resolveFeedLimit({ limit: 10 }, { unlimited: true })).toBe(Infinity)
157
+ })
158
+
159
+ it('returns the default limit when unlimited is explicitly false', () => {
160
+ expect(resolveFeedLimit(undefined, { unlimited: false })).toBe(30)
161
+ })
162
+ })
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { contactSchema } from './contact-schema'
4
+
5
+ describe('contactSchema', () => {
6
+ it('fails when name is too short', () => {
7
+ const result = contactSchema.safeParse({
8
+ name: 'ab',
9
+ email: 'valid@example.com',
10
+ message: 'a long enough message',
11
+ })
12
+
13
+ expect(result.success).toBe(false)
14
+ if (!result.success) {
15
+ expect(result.error.issues[0]?.message).toBe('Name must be at least 3 characters')
16
+ }
17
+ })
18
+
19
+ it('fails when email is invalid', () => {
20
+ const result = contactSchema.safeParse({
21
+ name: 'Kieran',
22
+ email: 'not-an-email',
23
+ message: 'a long enough message',
24
+ })
25
+
26
+ expect(result.success).toBe(false)
27
+ if (!result.success) {
28
+ expect(result.error.issues[0]?.message).toBe('Please enter a valid email')
29
+ }
30
+ })
31
+
32
+ it('fails when message is too short', () => {
33
+ const result = contactSchema.safeParse({
34
+ name: 'Kieran',
35
+ email: 'valid@example.com',
36
+ message: 'short',
37
+ })
38
+
39
+ expect(result.success).toBe(false)
40
+ if (!result.success) {
41
+ expect(result.error.issues[0]?.message).toBe('Message must be at least 8 characters')
42
+ }
43
+ })
44
+
45
+ it('passes with valid input', () => {
46
+ const result = contactSchema.safeParse({
47
+ name: 'Kieran',
48
+ email: 'valid@example.com',
49
+ message: 'a long enough message',
50
+ })
51
+
52
+ expect(result.success).toBe(true)
53
+ if (result.success) {
54
+ expect(result.data).toEqual({
55
+ name: 'Kieran',
56
+ email: 'valid@example.com',
57
+ message: 'a long enough message',
58
+ })
59
+ }
60
+ })
61
+
62
+ it('returns a safeParse shape with success/data or success/error', () => {
63
+ const passResult = contactSchema.safeParse({
64
+ name: 'Kieran',
65
+ email: 'valid@example.com',
66
+ message: 'a long enough message',
67
+ })
68
+ expect(passResult).toHaveProperty('success')
69
+ expect(passResult).toHaveProperty('data')
70
+
71
+ const failResult = contactSchema.safeParse({ name: '', email: '', message: '' })
72
+ expect(failResult).toHaveProperty('success')
73
+ expect(failResult).toHaveProperty('error')
74
+ })
75
+ })
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { FieldConfig } from '../types/fields'
4
+ import {
5
+ buildBaseInputProps,
6
+ buildFormFieldProps,
7
+ buildNumberInputProps,
8
+ buildTextInputProps,
9
+ } from './fieldProps'
10
+
11
+ describe('buildFormFieldProps', () => {
12
+ it('includes only required keys when optional args are undefined', () => {
13
+ const props = buildFormFieldProps({ name: 'email', required: true, size: 'md' })
14
+
15
+ expect(props).toEqual({ name: 'email', required: true, size: 'md' })
16
+ expect(props).not.toHaveProperty('label')
17
+ expect(props).not.toHaveProperty('class')
18
+ })
19
+
20
+ it('renames className to class when provided', () => {
21
+ const props = buildFormFieldProps({
22
+ name: 'email',
23
+ required: true,
24
+ size: 'md',
25
+ className: 'my-class',
26
+ })
27
+
28
+ expect(props).toHaveProperty('class', 'my-class')
29
+ expect(props).not.toHaveProperty('className')
30
+ })
31
+
32
+ it('includes label when provided', () => {
33
+ const props = buildFormFieldProps({
34
+ name: 'email',
35
+ required: false,
36
+ size: 'sm',
37
+ label: 'Email address',
38
+ })
39
+
40
+ expect(props).toEqual({ name: 'email', required: false, size: 'sm', label: 'Email address' })
41
+ })
42
+ })
43
+
44
+ describe('buildBaseInputProps', () => {
45
+ it('omits placeholder and leadingIcon when undefined', () => {
46
+ const props = buildBaseInputProps({ size: 'md' })
47
+
48
+ expect(props).toEqual({ size: 'md' })
49
+ expect(props).not.toHaveProperty('placeholder')
50
+ expect(props).not.toHaveProperty('leadingIcon')
51
+ })
52
+
53
+ it('renames icon to leadingIcon when provided', () => {
54
+ const props = buildBaseInputProps({ size: 'md', icon: 'i-lucide-mail' })
55
+
56
+ expect(props).toHaveProperty('leadingIcon', 'i-lucide-mail')
57
+ expect(props).not.toHaveProperty('icon')
58
+ })
59
+
60
+ it('includes placeholder when provided', () => {
61
+ const props = buildBaseInputProps({ size: 'md', placeholder: 'you@example.com' })
62
+
63
+ expect(props).toEqual({ size: 'md', placeholder: 'you@example.com' })
64
+ })
65
+ })
66
+
67
+ describe('buildTextInputProps', () => {
68
+ const baseConfig: FieldConfig = {
69
+ inputType: 'text',
70
+ validation: {} as FieldConfig['validation'],
71
+ }
72
+
73
+ it('omits inputmode, autocomplete, placeholder, leadingIcon when undefined', () => {
74
+ const props = buildTextInputProps({ config: baseConfig, size: 'md' })
75
+
76
+ expect(props).toEqual({ type: 'text', size: 'md' })
77
+ expect(props).not.toHaveProperty('inputmode')
78
+ expect(props).not.toHaveProperty('autocomplete')
79
+ expect(props).not.toHaveProperty('placeholder')
80
+ expect(props).not.toHaveProperty('leadingIcon')
81
+ })
82
+
83
+ it('includes inputmode and autocomplete from config when set', () => {
84
+ const config: FieldConfig = {
85
+ inputType: 'email',
86
+ inputMode: 'email',
87
+ autocomplete: 'email',
88
+ validation: {} as FieldConfig['validation'],
89
+ }
90
+
91
+ const props = buildTextInputProps({ config, size: 'lg' })
92
+
93
+ expect(props).toEqual({
94
+ type: 'email',
95
+ size: 'lg',
96
+ inputmode: 'email',
97
+ autocomplete: 'email',
98
+ })
99
+ })
100
+
101
+ it('renames icon to leadingIcon and includes placeholder when provided', () => {
102
+ const props = buildTextInputProps({
103
+ config: baseConfig,
104
+ size: 'md',
105
+ placeholder: 'Name',
106
+ icon: 'i-lucide-user',
107
+ })
108
+
109
+ expect(props).toEqual({
110
+ type: 'text',
111
+ size: 'md',
112
+ placeholder: 'Name',
113
+ leadingIcon: 'i-lucide-user',
114
+ })
115
+ })
116
+ })
117
+
118
+ describe('buildNumberInputProps', () => {
119
+ it('omits placeholder, leadingIcon, formatOptions when undefined', () => {
120
+ const props = buildNumberInputProps({ size: 'md' })
121
+
122
+ expect(props).toEqual({ size: 'md' })
123
+ expect(props).not.toHaveProperty('placeholder')
124
+ expect(props).not.toHaveProperty('leadingIcon')
125
+ expect(props).not.toHaveProperty('formatOptions')
126
+ })
127
+
128
+ it('includes formatOptions when provided', () => {
129
+ const formatOptions: Intl.NumberFormatOptions = { style: 'currency', currency: 'USD' }
130
+ const props = buildNumberInputProps({ size: 'md', formatOptions })
131
+
132
+ expect(props).toEqual({ size: 'md', formatOptions })
133
+ })
134
+
135
+ it('renames icon to leadingIcon when provided', () => {
136
+ const props = buildNumberInputProps({ size: 'md', icon: 'i-lucide-hash' })
137
+
138
+ expect(props).toHaveProperty('leadingIcon', 'i-lucide-hash')
139
+ expect(props).not.toHaveProperty('icon')
140
+ })
141
+ })