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,310 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ aspectClasses,
5
+ buildGridPlacementClasses,
6
+ buildGridPlacementStyle,
7
+ layerZIndex,
8
+ resolveAlignmentStyles,
9
+ resolveBleedStyles,
10
+ resolveDefaultPlacement,
11
+ resolveLayerStyles,
12
+ resolveResponsivePlacementVars,
13
+ resolveRhythmStyles,
14
+ } from './gridPlacementStyle'
15
+
16
+ describe('layerZIndex map', () => {
17
+ it('assigns ascending z-index values', () => {
18
+ expect(layerZIndex).toEqual({ back: 0, mid: 10, front: 20, top: 30 })
19
+ })
20
+ })
21
+
22
+ describe('aspectClasses map', () => {
23
+ it('maps aspect ratios to tailwind classes', () => {
24
+ expect(aspectClasses).toEqual({
25
+ '1/1': 'aspect-square',
26
+ '4/3': 'aspect-[4/3]',
27
+ '3/4': 'aspect-[3/4]',
28
+ '16/9': 'aspect-video',
29
+ '9/16': 'aspect-[9/16]',
30
+ '2/1': 'aspect-[2/1]',
31
+ '1/2': 'aspect-[1/2]',
32
+ })
33
+ })
34
+ })
35
+
36
+ describe('resolveDefaultPlacement', () => {
37
+ it('returns the fallback when value is undefined', () => {
38
+ expect(resolveDefaultPlacement(undefined, 'full')).toBe('full')
39
+ expect(resolveDefaultPlacement<number>(undefined, undefined)).toBeUndefined()
40
+ })
41
+
42
+ it('returns the plain value unchanged', () => {
43
+ expect(resolveDefaultPlacement(3, undefined)).toBe(3)
44
+ expect(resolveDefaultPlacement<string>('full', 'other')).toBe('full')
45
+ })
46
+
47
+ it('unwraps a responsive value default', () => {
48
+ expect(resolveDefaultPlacement({ default: 2, md: 4 }, undefined)).toBe(2)
49
+ })
50
+ })
51
+
52
+ describe('resolveResponsivePlacementVars', () => {
53
+ it('returns no vars for a plain value', () => {
54
+ expect(resolveResponsivePlacementVars(3, 'cs')).toEqual({})
55
+ })
56
+
57
+ it('returns no vars when value is undefined', () => {
58
+ expect(resolveResponsivePlacementVars(undefined, 'cs')).toEqual({})
59
+ })
60
+
61
+ it('sets only the md var when only md is provided', () => {
62
+ expect(resolveResponsivePlacementVars({ default: 1, md: 2 }, 'cs')).toEqual({
63
+ '--_md-cs': '2',
64
+ })
65
+ })
66
+
67
+ it('sets only the lg var when only lg is provided', () => {
68
+ expect(resolveResponsivePlacementVars({ default: 1, lg: 5 }, 're')).toEqual({
69
+ '--_lg-re': '5',
70
+ })
71
+ })
72
+
73
+ it('sets both md and lg vars when both are provided', () => {
74
+ expect(resolveResponsivePlacementVars({ default: 1, md: 2, lg: 5 }, 'ce')).toEqual({
75
+ '--_md-ce': '2',
76
+ '--_lg-ce': '5',
77
+ })
78
+ })
79
+ })
80
+
81
+ describe('resolveBleedStyles', () => {
82
+ it('returns no styles when bleed is not set', () => {
83
+ expect(resolveBleedStyles({})).toEqual({})
84
+ })
85
+
86
+ it('handles "both" bleed direction', () => {
87
+ expect(resolveBleedStyles({ bleed: 'both', rowStart: 1, rowSpan: 3 })).toEqual({
88
+ gridColumn: '1 / -1',
89
+ marginInline: 'calc(-1 * var(--grid-padding))',
90
+ gridRow: '1 / span 3',
91
+ })
92
+ })
93
+
94
+ it('handles "left" bleed direction with a numeric colSpan', () => {
95
+ expect(
96
+ resolveBleedStyles({ bleed: 'left', colSpan: 4, rowStart: 2, rowSpan: 1 })
97
+ ).toEqual({
98
+ gridColumn: '1 / span 4',
99
+ marginInlineStart: 'calc(-1 * var(--grid-padding))',
100
+ gridRow: '2 / span 1',
101
+ })
102
+ })
103
+
104
+ it('handles "left" bleed direction without a numeric colSpan', () => {
105
+ expect(
106
+ resolveBleedStyles({ bleed: 'left', colSpan: 'full', rowSpan: 1 })
107
+ ).toEqual({
108
+ gridColumn: '1 / -1',
109
+ marginInlineStart: 'calc(-1 * var(--grid-padding))',
110
+ gridRow: 'auto / span 1',
111
+ })
112
+ })
113
+
114
+ it('handles "right" bleed direction', () => {
115
+ expect(
116
+ resolveBleedStyles({ bleed: 'right', colStart: 3, rowSpan: 2 })
117
+ ).toEqual({
118
+ gridColumn: '3 / -1',
119
+ marginInlineEnd: 'calc(-1 * var(--grid-padding))',
120
+ gridRow: 'auto / span 2',
121
+ })
122
+ })
123
+
124
+ it('defaults gridRow parts to "auto" when start/span are missing', () => {
125
+ expect(resolveBleedStyles({ bleed: 'right' })).toEqual({
126
+ gridColumn: 'auto / -1',
127
+ marginInlineEnd: 'calc(-1 * var(--grid-padding))',
128
+ gridRow: 'auto / span undefined',
129
+ })
130
+ })
131
+ })
132
+
133
+ describe('resolveAlignmentStyles', () => {
134
+ it('returns no styles when neither align nor justify is set', () => {
135
+ expect(resolveAlignmentStyles()).toEqual({})
136
+ })
137
+
138
+ it('defaults the missing axis to stretch', () => {
139
+ expect(resolveAlignmentStyles('center')).toEqual({
140
+ display: 'grid',
141
+ width: '100%',
142
+ height: '100%',
143
+ placeItems: 'center stretch',
144
+ })
145
+ expect(resolveAlignmentStyles(undefined, 'end')).toEqual({
146
+ display: 'grid',
147
+ width: '100%',
148
+ height: '100%',
149
+ placeItems: 'stretch end',
150
+ })
151
+ })
152
+
153
+ it('combines align and justify', () => {
154
+ expect(resolveAlignmentStyles('start', 'end')).toEqual({
155
+ display: 'grid',
156
+ width: '100%',
157
+ height: '100%',
158
+ placeItems: 'start end',
159
+ })
160
+ })
161
+ })
162
+
163
+ describe('resolveRhythmStyles', () => {
164
+ it('returns no styles when density is undefined', () => {
165
+ expect(resolveRhythmStyles()).toEqual({})
166
+ })
167
+
168
+ it.each([
169
+ ['compact', '0.125rem'],
170
+ ['normal', '0.25rem'],
171
+ ['relaxed', '0.5rem'],
172
+ ] as const)('maps %s density to the expected rhythm value', (density, value) => {
173
+ expect(resolveRhythmStyles(density)).toEqual({ '--rhythm': value })
174
+ })
175
+ })
176
+
177
+ describe('resolveLayerStyles', () => {
178
+ it('returns no styles when neither z nor layer is set', () => {
179
+ expect(resolveLayerStyles()).toEqual({})
180
+ })
181
+
182
+ it('prefers an explicit z over the layer lookup', () => {
183
+ expect(resolveLayerStyles(99, 'back')).toEqual({ zIndex: '99' })
184
+ })
185
+
186
+ it('falls back to the layer z-index lookup', () => {
187
+ expect(resolveLayerStyles(undefined, 'front')).toEqual({ zIndex: '20' })
188
+ })
189
+
190
+ it('treats z=0 as a defined value', () => {
191
+ expect(resolveLayerStyles(0, 'top')).toEqual({ zIndex: '0' })
192
+ })
193
+ })
194
+
195
+ describe('buildGridPlacementStyle', () => {
196
+ it('applies defaults for an empty input', () => {
197
+ expect(buildGridPlacementStyle({})).toEqual({
198
+ gridColumn: '1 / -1',
199
+ '--_rs': 'auto',
200
+ '--_re': '1',
201
+ })
202
+ })
203
+
204
+ it('returns early for a full colSpan and skips alignment/rhythm/layer/gap', () => {
205
+ const style = buildGridPlacementStyle({
206
+ colSpan: 'full',
207
+ align: 'center',
208
+ density: 'compact',
209
+ z: 5,
210
+ gap: '1rem',
211
+ })
212
+
213
+ expect(style).toEqual({
214
+ gridColumn: '1 / -1',
215
+ '--_rs': 'auto',
216
+ '--_re': '1',
217
+ })
218
+ expect(style).not.toHaveProperty('zIndex')
219
+ expect(style).not.toHaveProperty('--rhythm')
220
+ expect(style).not.toHaveProperty('--grid-gap')
221
+ })
222
+
223
+ it('builds explicit column/row vars for a non-full colSpan and applies alignment/rhythm/layer/gap', () => {
224
+ const style = buildGridPlacementStyle({
225
+ colStart: 2,
226
+ colSpan: 3,
227
+ rowStart: 1,
228
+ rowSpan: 2,
229
+ align: 'center',
230
+ justify: 'end',
231
+ density: 'relaxed',
232
+ z: 5,
233
+ gap: '2rem',
234
+ })
235
+
236
+ expect(style).toEqual({
237
+ '--_cs': '2',
238
+ '--_ce': '3',
239
+ '--_rs': '1',
240
+ '--_re': '2',
241
+ display: 'grid',
242
+ width: '100%',
243
+ height: '100%',
244
+ placeItems: 'center end',
245
+ '--rhythm': '0.5rem',
246
+ zIndex: '5',
247
+ '--grid-gap': '2rem',
248
+ })
249
+ })
250
+
251
+ it('includes responsive vars for responsive-value inputs on a non-full colSpan', () => {
252
+ const style = buildGridPlacementStyle({
253
+ colStart: { default: 1, md: 2 },
254
+ colSpan: { default: 3, lg: 6 },
255
+ })
256
+
257
+ expect(style['--_md-cs']).toBe('2')
258
+ expect(style['--_lg-ce']).toBe('6')
259
+ })
260
+
261
+ it('applies bleed styles and skips the column/row var block entirely', () => {
262
+ const style = buildGridPlacementStyle({
263
+ bleed: 'both',
264
+ rowStart: 1,
265
+ rowSpan: 3,
266
+ align: 'start',
267
+ })
268
+
269
+ expect(style).toEqual({
270
+ gridColumn: '1 / -1',
271
+ marginInline: 'calc(-1 * var(--grid-padding))',
272
+ gridRow: '1 / span 3',
273
+ display: 'grid',
274
+ width: '100%',
275
+ height: '100%',
276
+ placeItems: 'start stretch',
277
+ })
278
+ expect(style).not.toHaveProperty('--_cs')
279
+ expect(style).not.toHaveProperty('--_rs')
280
+ })
281
+
282
+ it('omits --grid-gap when gap is not provided', () => {
283
+ const style = buildGridPlacementStyle({ colStart: 1, colSpan: 2 })
284
+ expect(style).not.toHaveProperty('--grid-gap')
285
+ })
286
+ })
287
+
288
+ describe('buildGridPlacementClasses', () => {
289
+ it('returns the base classes when no aspect/container is set', () => {
290
+ expect(buildGridPlacementClasses({})).toBe('gi-placed @container @container/item')
291
+ })
292
+
293
+ it('appends the aspect class when aspect is set', () => {
294
+ expect(buildGridPlacementClasses({ aspect: '16/9' })).toBe(
295
+ 'gi-placed @container @container/item aspect-video'
296
+ )
297
+ })
298
+
299
+ it('appends the container class when container is set', () => {
300
+ expect(buildGridPlacementClasses({ container: 'wide' })).toBe(
301
+ 'gi-placed @container @container/item layout-container-wide'
302
+ )
303
+ })
304
+
305
+ it('appends both aspect and container classes when both are set', () => {
306
+ expect(buildGridPlacementClasses({ aspect: '1/1', container: 'fluid' })).toBe(
307
+ 'gi-placed @container @container/item aspect-square layout-container-fluid'
308
+ )
309
+ })
310
+ })
@@ -26,7 +26,7 @@ export default defineNuxtConfig({
26
26
  })
27
27
 
28
28
  declare module '@nuxt/schema' {
29
- interface RuntimeConfig {
29
+ type RuntimeConfig = {
30
30
  metadataComicvine: {
31
31
  apiKey: string
32
32
  baseUrl: string
@@ -1,14 +1,19 @@
1
1
  import type { ComicVineApiResponse } from './types'
2
2
 
3
- const FIELD_LIST_ISSUE = 'id,name,issue_number,description,deck,cover_date,store_date,site_detail_url,image,volume,person_credits,publisher'
4
- const FIELD_LIST_VOLUME = 'id,name,description,deck,start_year,site_detail_url,image,publisher,people'
3
+ const FIELD_LIST_ISSUE =
4
+ 'id,name,issue_number,description,deck,cover_date,store_date,site_detail_url,image,volume,person_credits,publisher'
5
+ const FIELD_LIST_VOLUME =
6
+ 'id,name,description,deck,start_year,count_of_issues,site_detail_url,image,publisher,people'
5
7
 
6
8
  function getConfig() {
7
9
  const config = useRuntimeConfig()
8
10
  return config.metadataComicvine
9
11
  }
10
12
 
11
- async function comicVineFetch<T>(path: string, params: Record<string, string>): Promise<ComicVineApiResponse<T>> {
13
+ async function comicVineFetch<T>(
14
+ path: string,
15
+ params: Record<string, string>
16
+ ): Promise<ComicVineApiResponse<T>> {
12
17
  const { apiKey, baseUrl } = getConfig()
13
18
 
14
19
  const url = new URL(`${baseUrl}${path}`)
@@ -16,13 +21,15 @@ async function comicVineFetch<T>(path: string, params: Record<string, string>):
16
21
  url.searchParams.set('format', 'json')
17
22
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
18
23
 
19
- const res = await $fetch<ComicVineApiResponse<T>>(url.toString())
24
+ const res = await $fetch<ComicVineApiResponse<T>>(url.toString(), {
25
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; nuxt-metadata/1.0)' },
26
+ })
20
27
  if (res.status_code !== 1) throw new MetadataProviderError('comicvine', `API error: ${res.error}`)
21
28
  return res
22
29
  }
23
30
 
24
31
  export async function searchComicVineIssues(query: string, limit = 10) {
25
- return comicVineFetch<import('./types').ComicVineIssue[]>('/search/', {
32
+ return comicVineFetch<Array<import('./types').ComicVineIssue>>('/search/', {
26
33
  query,
27
34
  resources: 'issue',
28
35
  limit: String(limit),
@@ -31,7 +38,7 @@ export async function searchComicVineIssues(query: string, limit = 10) {
31
38
  }
32
39
 
33
40
  export async function searchComicVineVolumes(query: string, limit = 10) {
34
- return comicVineFetch<import('./types').ComicVineVolume[]>('/search/', {
41
+ return comicVineFetch<Array<import('./types').ComicVineVolume>>('/search/', {
35
42
  query,
36
43
  resources: 'volume',
37
44
  limit: String(limit),
@@ -0,0 +1,201 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { normaliseComicVineIssue, normaliseComicVineVolume } from './normalise'
4
+ import type { ComicVineIssue, ComicVineVolume } from './types'
5
+
6
+ function baseIssue(overrides: Partial<ComicVineIssue> = {}): ComicVineIssue {
7
+ return {
8
+ id: 1000,
9
+ name: null,
10
+ issue_number: '1',
11
+ description: null,
12
+ deck: null,
13
+ cover_date: null,
14
+ store_date: null,
15
+ site_detail_url: 'https://comicvine.gamespot.com/issue/1000',
16
+ image: null,
17
+ volume: null,
18
+ person_credits: null,
19
+ publisher: null,
20
+ ...overrides,
21
+ }
22
+ }
23
+
24
+ function baseVolume(overrides: Partial<ComicVineVolume> = {}): ComicVineVolume {
25
+ return {
26
+ id: 2000,
27
+ name: 'Amazing Spider-Man',
28
+ description: null,
29
+ deck: null,
30
+ start_year: null,
31
+ count_of_issues: null,
32
+ site_detail_url: 'https://comicvine.gamespot.com/volume/2000',
33
+ image: null,
34
+ publisher: null,
35
+ people: null,
36
+ ...overrides,
37
+ }
38
+ }
39
+
40
+ describe('normaliseComicVineIssue', () => {
41
+ it('prefers volume name + issue number for the title', () => {
42
+ const record = normaliseComicVineIssue(
43
+ baseIssue({ volume: { id: 1, name: 'Amazing Spider-Man' }, issue_number: '5' })
44
+ )
45
+ expect(record.title).toBe('Amazing Spider-Man #5')
46
+ })
47
+
48
+ it('falls back to the issue name when there is no volume', () => {
49
+ const record = normaliseComicVineIssue(baseIssue({ name: 'The Beginning', issue_number: '1' }))
50
+ expect(record.title).toBe('The Beginning')
51
+ })
52
+
53
+ it('falls back to "Issue #n" when neither volume nor name are present', () => {
54
+ const record = normaliseComicVineIssue(baseIssue({ issue_number: '7' }))
55
+ expect(record.title).toBe('Issue #7')
56
+ })
57
+
58
+ it('sets subtitle to the issue name when present', () => {
59
+ const record = normaliseComicVineIssue(baseIssue({ name: 'The Beginning' }))
60
+ expect(record.subtitle).toBe('The Beginning')
61
+ })
62
+
63
+ it('leaves subtitle undefined when the issue has no name', () => {
64
+ expect(normaliseComicVineIssue(baseIssue()).subtitle).toBeUndefined()
65
+ })
66
+
67
+ it('prefers deck over description', () => {
68
+ const record = normaliseComicVineIssue(
69
+ baseIssue({ deck: 'Short deck', description: 'Long description' })
70
+ )
71
+ expect(record.description).toBe('Short deck')
72
+ })
73
+
74
+ it('falls back to description when deck is absent', () => {
75
+ const record = normaliseComicVineIssue(baseIssue({ description: 'Long description' }))
76
+ expect(record.description).toBe('Long description')
77
+ })
78
+
79
+ it('leaves description undefined when both deck and description are absent', () => {
80
+ expect(normaliseComicVineIssue(baseIssue()).description).toBeUndefined()
81
+ })
82
+
83
+ it('maps person_credits to creators', () => {
84
+ const record = normaliseComicVineIssue(
85
+ baseIssue({ person_credits: [{ id: 10, name: 'Stan Lee', role: 'writer' }] })
86
+ )
87
+ expect(record.creators).toEqual([{ name: 'Stan Lee', role: 'writer', providerId: '10' }])
88
+ })
89
+
90
+ it('leaves creators undefined when person_credits is empty or null', () => {
91
+ expect(normaliseComicVineIssue(baseIssue({ person_credits: [] })).creators).toBeUndefined()
92
+ expect(normaliseComicVineIssue(baseIssue({ person_credits: null })).creators).toBeUndefined()
93
+ })
94
+
95
+ it('prefers medium_url over original_url for coverUrl', () => {
96
+ const record = normaliseComicVineIssue(
97
+ baseIssue({ image: { medium_url: 'medium.jpg', original_url: 'original.jpg' } })
98
+ )
99
+ expect(record.coverUrl).toBe('medium.jpg')
100
+ })
101
+
102
+ it('falls back to original_url when medium_url is missing', () => {
103
+ const record = normaliseComicVineIssue(
104
+ baseIssue({
105
+ image: { medium_url: undefined as unknown as string, original_url: 'original.jpg' },
106
+ })
107
+ )
108
+ expect(record.coverUrl).toBe('original.jpg')
109
+ })
110
+
111
+ it('leaves coverUrl undefined when image is null', () => {
112
+ expect(normaliseComicVineIssue(baseIssue()).coverUrl).toBeUndefined()
113
+ })
114
+
115
+ it('sets identifiers.comicVineId to the stringified id', () => {
116
+ expect(normaliseComicVineIssue(baseIssue({ id: 42 })).identifiers).toEqual({
117
+ comicVineId: '42',
118
+ })
119
+ })
120
+
121
+ it('stamps lastSyncedAt as an ISO-ish string', () => {
122
+ expect(normaliseComicVineIssue(baseIssue()).lastSyncedAt).toEqual(expect.any(String))
123
+ })
124
+ })
125
+
126
+ describe('normaliseComicVineVolume', () => {
127
+ it('appends the start year to the title when present', () => {
128
+ expect(normaliseComicVineVolume(baseVolume({ start_year: '1963' })).title).toBe(
129
+ 'Amazing Spider-Man (1963)'
130
+ )
131
+ })
132
+
133
+ it('omits the year suffix when start_year is absent', () => {
134
+ expect(normaliseComicVineVolume(baseVolume()).title).toBe('Amazing Spider-Man')
135
+ })
136
+
137
+ it('pluralises the issue count subtitle for more than one issue', () => {
138
+ expect(normaliseComicVineVolume(baseVolume({ count_of_issues: 5 })).subtitle).toBe('5 issues')
139
+ })
140
+
141
+ it('uses the singular issue count subtitle for exactly one issue', () => {
142
+ expect(normaliseComicVineVolume(baseVolume({ count_of_issues: 1 })).subtitle).toBe('1 issue')
143
+ })
144
+
145
+ it('joins publisher and issue count with " · "', () => {
146
+ const record = normaliseComicVineVolume(
147
+ baseVolume({ publisher: { id: 1, name: 'Marvel' }, count_of_issues: 3 })
148
+ )
149
+ expect(record.subtitle).toBe('Marvel · 3 issues')
150
+ })
151
+
152
+ it('drops the null issue count and keeps only the publisher', () => {
153
+ const record = normaliseComicVineVolume(
154
+ baseVolume({ publisher: { id: 1, name: 'Marvel' }, count_of_issues: null })
155
+ )
156
+ expect(record.subtitle).toBe('Marvel')
157
+ })
158
+
159
+ it('subtitle is undefined when both publisher and issue count are absent', () => {
160
+ expect(normaliseComicVineVolume(baseVolume()).subtitle).toBeUndefined()
161
+ })
162
+
163
+ it('maps people to creators', () => {
164
+ const record = normaliseComicVineVolume(
165
+ baseVolume({ people: [{ id: 5, name: 'Stan Lee', role: 'writer' }] })
166
+ )
167
+ expect(record.creators).toEqual([{ name: 'Stan Lee', role: 'writer', providerId: '5' }])
168
+ })
169
+
170
+ it('leaves creators undefined when people is empty or null', () => {
171
+ expect(normaliseComicVineVolume(baseVolume({ people: [] })).creators).toBeUndefined()
172
+ expect(normaliseComicVineVolume(baseVolume({ people: null })).creators).toBeUndefined()
173
+ })
174
+
175
+ it('prefers deck over description', () => {
176
+ const record = normaliseComicVineVolume(
177
+ baseVolume({ deck: 'deck text', description: 'desc text' })
178
+ )
179
+ expect(record.description).toBe('deck text')
180
+ })
181
+
182
+ it('prefers medium_url over original_url, falling back when missing', () => {
183
+ const withMedium = normaliseComicVineVolume(
184
+ baseVolume({ image: { medium_url: 'medium.jpg', original_url: 'original.jpg' } })
185
+ )
186
+ expect(withMedium.coverUrl).toBe('medium.jpg')
187
+
188
+ const withoutMedium = normaliseComicVineVolume(
189
+ baseVolume({
190
+ image: { medium_url: undefined as unknown as string, original_url: 'original.jpg' },
191
+ })
192
+ )
193
+ expect(withoutMedium.coverUrl).toBe('original.jpg')
194
+ })
195
+
196
+ it('sets identifiers.comicVineId to the stringified id', () => {
197
+ expect(normaliseComicVineVolume(baseVolume({ id: 99 })).identifiers).toEqual({
198
+ comicVineId: '99',
199
+ })
200
+ })
201
+ })
@@ -1,4 +1,5 @@
1
- import type { MetadataRecord, MetadataCreator } from '#layers/metadata/shared/types'
1
+ import type { MetadataCreator, MetadataRecord } from '#layers/metadata/shared/types'
2
+
2
3
  import type { ComicVineIssue, ComicVineVolume } from './types'
3
4
 
4
5
  // fallow-ignore-next-line complexity
@@ -9,18 +10,24 @@ export function normaliseComicVineIssue(issue: ComicVineIssue): MetadataRecord {
9
10
  providerId: String(p.id),
10
11
  }))
11
12
 
13
+ const description = issue.deck ?? issue.description
14
+ const publishedAt = issue.cover_date ?? issue.store_date
15
+ const coverUrl = issue.image?.medium_url ?? issue.image?.original_url
16
+
12
17
  return {
13
18
  id: `comicvine:issue:${issue.id}`,
14
19
  provider: 'comicvine',
15
20
  providerId: String(issue.id),
16
21
  mediaType: 'comic',
17
- title: issue.volume?.name ? `${issue.volume.name} #${issue.issue_number}` : (issue.name ?? `Issue #${issue.issue_number}`),
18
- subtitle: issue.name ?? undefined,
19
- description: issue.deck ?? issue.description ?? undefined,
20
- creators: creators.length ? creators : undefined,
21
- publisher: issue.publisher?.name ?? undefined,
22
- publishedAt: issue.cover_date ?? issue.store_date ?? undefined,
23
- coverUrl: issue.image?.medium_url ?? issue.image?.original_url ?? undefined,
22
+ title: issue.volume?.name
23
+ ? `${issue.volume.name} #${issue.issue_number}`
24
+ : (issue.name ?? `Issue #${issue.issue_number}`),
25
+ ...(issue.name && { subtitle: issue.name }),
26
+ ...(description && { description }),
27
+ ...(creators.length && { creators }),
28
+ ...(issue.publisher?.name && { publisher: issue.publisher.name }),
29
+ ...(publishedAt && { publishedAt }),
30
+ ...(coverUrl && { coverUrl }),
24
31
  identifiers: { comicVineId: String(issue.id) },
25
32
  sourceUrl: issue.site_detail_url,
26
33
  raw: issue,
@@ -36,17 +43,29 @@ export function normaliseComicVineVolume(volume: ComicVineVolume): MetadataRecor
36
43
  providerId: String(p.id),
37
44
  }))
38
45
 
46
+ const subtitle = [
47
+ volume.publisher?.name,
48
+ volume.count_of_issues
49
+ ? `${volume.count_of_issues} issue${volume.count_of_issues === 1 ? '' : 's'}`
50
+ : null,
51
+ ]
52
+ .filter(Boolean)
53
+ .join(' · ')
54
+ const description = volume.deck ?? volume.description
55
+ const coverUrl = volume.image?.medium_url ?? volume.image?.original_url
56
+
39
57
  return {
40
58
  id: `comicvine:volume:${volume.id}`,
41
59
  provider: 'comicvine',
42
60
  providerId: String(volume.id),
43
- mediaType: 'comic',
44
- title: volume.name,
45
- description: volume.deck ?? volume.description ?? undefined,
46
- creators: creators.length ? creators : undefined,
47
- publisher: volume.publisher?.name ?? undefined,
48
- publishedAt: volume.start_year ?? undefined,
49
- coverUrl: volume.image?.medium_url ?? volume.image?.original_url ?? undefined,
61
+ mediaType: 'comic-series',
62
+ title: volume.start_year ? `${volume.name} (${volume.start_year})` : volume.name,
63
+ ...(subtitle && { subtitle }),
64
+ ...(description && { description }),
65
+ ...(creators.length && { creators }),
66
+ ...(volume.publisher?.name && { publisher: volume.publisher.name }),
67
+ ...(volume.start_year && { publishedAt: volume.start_year }),
68
+ ...(coverUrl && { coverUrl }),
50
69
  identifiers: { comicVineId: String(volume.id) },
51
70
  sourceUrl: volume.site_detail_url,
52
71
  raw: volume,