kmcom-nuxt-layers 6.4.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 (129) hide show
  1. package/docs/MIGRATION-v6.5.0.md +87 -0
  2. package/docs/MIGRATION.md +2 -1
  3. package/docs/USAGE.md +35 -10
  4. package/layers/content/app/components/content/Figure.vue +1 -1
  5. package/layers/content/app/composables/useContentSection.ts +1 -1
  6. package/layers/core/app/app.config.ts +7 -3
  7. package/layers/core/app/components/LoadingScreen.vue +35 -21
  8. package/layers/core/app/composables/useCache.ts +2 -2
  9. package/layers/core/app/composables/useErrorLog.ts +5 -5
  10. package/layers/core/app/composables/useLoading.ts +19 -10
  11. package/layers/core/app/plugins/loading.client.ts +11 -3
  12. package/layers/core/app/types/loading.ts +10 -3
  13. package/layers/core/app/utils/loading.ts +6 -15
  14. package/layers/core/nuxt.config.ts +14 -14
  15. package/layers/core/server/utils/app-config.ts +17 -0
  16. package/layers/data/auth/app/middleware/auth.ts +1 -1
  17. package/layers/data/forms/app/components/Form/Contact.vue +1 -1
  18. package/layers/data/forms/server/api/contact.post.ts +16 -8
  19. package/layers/data/forms/server/api/forms/status.get.ts +3 -0
  20. package/layers/data/forms/{app → shared}/utils/contact-schema.ts +2 -1
  21. package/layers/data/metadata/server/api/metadata/search.get.ts +27 -7
  22. package/layers/data/metadata/server/api/metadata/status.get.ts +4 -1
  23. package/layers/data/metadata/server/api/metadata/sync.post.ts +13 -3
  24. package/layers/data/metadata/server/utils/metadata/resolve-record.ts +3 -3
  25. package/layers/delivery/feeds/nuxt.config.ts +6 -1
  26. package/layers/delivery/feeds/package.json +2 -2
  27. package/layers/delivery/feeds/server/routes/feed/demo.get.ts +2 -2
  28. package/layers/delivery/feeds/server/utils/content-adapter.ts +1 -1
  29. package/layers/delivery/feeds/server/utils/feed-service.ts +2 -2
  30. package/layers/design-system/theming/app/plugins/theme.client.ts +7 -0
  31. package/layers/design-system/theming/nuxt.config.ts +0 -2
  32. package/layers/motion/scroll/app/plugins/locomotive-scroll.client.ts +3 -2
  33. package/layers/render/canvas/nuxt.config.ts +0 -4
  34. package/layers/render/shader/nuxt.config.ts +0 -4
  35. package/layers/structure/routing/app/middleware/01.maintenance.global.ts +4 -1
  36. package/layers/structure/routing/app/middleware/02.governance.global.ts +1 -1
  37. package/layers/structure/routing/nuxt.config.ts +19 -12
  38. package/layers/structure/routing/server/api/feature-flags.get.ts +4 -2
  39. package/package.json +35 -5
  40. package/docs/FALLOW-COMPLEXITY-DUPLICATION-AUDIT.md +0 -64
  41. package/docs/IMPROVE-AUDIT-README.md +0 -29
  42. package/docs/IMPROVE-AUDIT-RESULTS.md +0 -51
  43. package/docs/IMPROVE-DEEP-AUDIT-RESULTS.md +0 -81
  44. package/docs/fallow-refactor/apps-debug.md +0 -27
  45. package/docs/fallow-refactor/apps-playground.md +0 -46
  46. package/docs/fallow-refactor/apps-visual-identity.md +0 -41
  47. package/docs/fallow-refactor/layers-animations.md +0 -34
  48. package/docs/fallow-refactor/layers-canvas.md +0 -32
  49. package/docs/fallow-refactor/layers-content.md +0 -33
  50. package/docs/fallow-refactor/layers-core.md +0 -39
  51. package/docs/fallow-refactor/layers-feeds.md +0 -39
  52. package/docs/fallow-refactor/layers-forms.md +0 -30
  53. package/docs/fallow-refactor/layers-layout.md +0 -42
  54. package/docs/fallow-refactor/layers-mailer.md +0 -32
  55. package/docs/fallow-refactor/layers-motion.md +0 -27
  56. package/docs/fallow-refactor/layers-navigation.md +0 -31
  57. package/docs/fallow-refactor/layers-page-transitions.md +0 -30
  58. package/docs/fallow-refactor/layers-routing.md +0 -33
  59. package/docs/fallow-refactor/layers-scripts.md +0 -35
  60. package/docs/fallow-refactor/layers-scroll.md +0 -38
  61. package/docs/fallow-refactor/layers-seo.md +0 -32
  62. package/docs/fallow-refactor/layers-shader.md +0 -53
  63. package/docs/fallow-refactor/layers-theme.md +0 -33
  64. package/docs/fallow-refactor/layers-transitions.md +0 -27
  65. package/docs/fallow-refactor/layers-typography.md +0 -29
  66. package/docs/fallow-refactor/layers-visual.md +0 -34
  67. package/docs/superpowers/plans/2026-09-03-layout-layer-fluid-rebuild.md +0 -1850
  68. package/docs/superpowers/plans/2026-09-03-vue-element-system.md +0 -1019
  69. package/docs/superpowers/plans/2026-09-04-grid-layer.md +0 -977
  70. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-0-1-design.md +0 -533
  71. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-2-design.md +0 -154
  72. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-3-design.md +0 -109
  73. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-4-design.md +0 -124
  74. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-5-design.md +0 -121
  75. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-6-design.md +0 -77
  76. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-7-design.md +0 -73
  77. package/docs/superpowers/specs/2026-08-31-swiss-grid-layer-design.md +0 -190
  78. package/docs/superpowers/specs/2026-09-03-layout-layer-fluid-rebuild-design.md +0 -312
  79. package/docs/superpowers/specs/2026-09-03-vue-element-system-design.md +0 -342
  80. package/docs/superpowers/specs/2026-09-04-grid-layer-design.md +0 -236
  81. package/layers/content/app/utils/galleryImageMeta.test.ts +0 -30
  82. package/layers/core/app/components/Element.test.ts +0 -90
  83. package/layers/core/app/components/ElementTw.test.ts +0 -64
  84. package/layers/core/app/composables/useLayoutAttrs.test.ts +0 -77
  85. package/layers/core/app/utils/browserInfo.test.ts +0 -81
  86. package/layers/core/app/utils/featureClasses.test.ts +0 -101
  87. package/layers/core/app/utils/loading.test.ts +0 -48
  88. package/layers/core/app/utils/resolveElementStyle.test.ts +0 -99
  89. package/layers/core/app/utils/scrollGuardClamp.test.ts +0 -36
  90. package/layers/core/app/utils/styleToTailwindClasses.test.ts +0 -31
  91. package/layers/data/forms/app/utils/contact-schema.test.ts +0 -75
  92. package/layers/data/forms/app/utils/fieldProps.test.ts +0 -141
  93. package/layers/data/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.test.ts +0 -201
  94. package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/client.test.ts +0 -76
  95. package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/normalise.test.ts +0 -144
  96. package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.test.ts +0 -25
  97. package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.test.ts +0 -144
  98. package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.test.ts +0 -29
  99. package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.test.ts +0 -162
  100. package/layers/data/metadata/server/utils/metadata/cache.test.ts +0 -39
  101. package/layers/data/metadata/server/utils/metadata/provider-fetch.test.ts +0 -27
  102. package/layers/data/metadata/server/utils/metadata/provider-registry.test.ts +0 -54
  103. package/layers/data/metadata/server/utils/metadata/resolve-record.test.ts +0 -87
  104. package/layers/data/metadata/server/utils/metadata/search.test.ts +0 -204
  105. package/layers/delivery/feeds/server/utils/content-adapter.test.ts +0 -67
  106. package/layers/delivery/feeds/server/utils/feed-author.test.ts +0 -71
  107. package/layers/delivery/feeds/server/utils/feed-config.test.ts +0 -184
  108. package/layers/delivery/feeds/server/utils/formats.test.ts +0 -71
  109. package/layers/delivery/seo/app/utils/seoConfig.test.ts +0 -58
  110. package/layers/design-system/theming/server/utils/accent-css.test.ts +0 -71
  111. package/layers/design-system/theming/server/utils/fouc-config.test.ts +0 -78
  112. package/layers/design-system/typography/app/components/Typography/Headline.test.ts +0 -36
  113. package/layers/design-system/typography/app/components/Typography/index.test.ts +0 -31
  114. package/layers/design-system/typography/app/composables/color.test.ts +0 -30
  115. package/layers/design-system/typography/app/composables/typography.test.ts +0 -53
  116. package/layers/design-system/visual/app/components/HoverDimGroup.test.ts +0 -38
  117. package/layers/design-system/visual/app/composables/tint.test.ts +0 -22
  118. package/layers/design-system/visual/app/utils/colorTokens.test.ts +0 -51
  119. package/layers/design-system/visual/app/utils/gradientStyle.test.ts +0 -75
  120. package/layers/design-system/visual/app/utils/responsiveSizes.test.ts +0 -71
  121. package/layers/motion/animations/app/components/Motion/Staggered.test.ts +0 -40
  122. package/layers/motion/scroll/app/composables/useReducedMotion.test.ts +0 -23
  123. package/layers/motion/scroll/app/utils/parallax.test.ts +0 -24
  124. package/layers/structure/grid/app/composables/useGridTracks.test.ts +0 -94
  125. package/layers/structure/grid/app/types/tracks.test.ts +0 -32
  126. package/layers/structure/grid/app/utils/placement.test.ts +0 -19
  127. package/layers/structure/layout/app/utils/gridPlacementStyle.test.ts +0 -269
  128. package/layers/structure/navigation/app/utils/site.test.ts +0 -39
  129. package/layers/structure/routing/app/utils/resolveRoute.test.ts +0 -47
@@ -1,75 +0,0 @@
1
- import { describe, expect, it } from 'vite-plus/test'
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
- })
@@ -1,141 +0,0 @@
1
- import { describe, expect, it } from 'vite-plus/test'
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
- })
@@ -1,201 +0,0 @@
1
- import { describe, expect, it } from 'vite-plus/test'
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,76 +0,0 @@
1
- import { describe, expect, it } from 'vite-plus/test'
2
-
3
- import { googleBooksCoverUrl, googleBooksMediaType } from './client'
4
-
5
- describe('googleBooksMediaType', () => {
6
- it('classifies manga from categories', () => {
7
- expect(googleBooksMediaType(['Comics & Graphic Novels / Manga'])).toBe('manga')
8
- })
9
-
10
- it('classifies comic as graphic-novel', () => {
11
- expect(googleBooksMediaType(['Comics & Graphic Novels / Comic'])).toBe('graphic-novel')
12
- })
13
-
14
- it('classifies "graphic novel" as graphic-novel', () => {
15
- expect(googleBooksMediaType(['Fiction / Graphic Novel'])).toBe('graphic-novel')
16
- })
17
-
18
- it('is case-insensitive', () => {
19
- expect(googleBooksMediaType(['MANGA'])).toBe('manga')
20
- expect(googleBooksMediaType(['COMIC'])).toBe('graphic-novel')
21
- })
22
-
23
- it('defaults to book for unrelated categories', () => {
24
- expect(googleBooksMediaType(['Fiction / Thriller'])).toBe('book')
25
- })
26
-
27
- it('defaults to book when categories is undefined', () => {
28
- expect(googleBooksMediaType(undefined)).toBe('book')
29
- })
30
-
31
- it('defaults to book when categories is empty', () => {
32
- expect(googleBooksMediaType([])).toBe('book')
33
- })
34
-
35
- it('prefers manga over comic when both are present', () => {
36
- expect(googleBooksMediaType(['Manga / Comic'])).toBe('manga')
37
- })
38
- })
39
-
40
- describe('googleBooksCoverUrl', () => {
41
- it('upgrades http to https', () => {
42
- const url = googleBooksCoverUrl('http://books.google.com/books/content?id=abc&zoom=1')
43
- expect(url.startsWith('https://')).toBe(true)
44
- })
45
-
46
- it('rewrites zoom=N to zoom=3', () => {
47
- const url = googleBooksCoverUrl('https://books.google.com/books/content?id=abc&zoom=1')
48
- expect(url).toContain('&zoom=3')
49
- expect(url).not.toContain('&zoom=1')
50
- })
51
-
52
- it('strips &edge=curl', () => {
53
- const url = googleBooksCoverUrl(
54
- 'https://books.google.com/books/content?id=abc&zoom=1&edge=curl'
55
- )
56
- expect(url).not.toContain('edge=curl')
57
- })
58
-
59
- it('appends &fife=w{width} using the default width of 800', () => {
60
- const url = googleBooksCoverUrl('https://books.google.com/books/content?id=abc&zoom=1')
61
- expect(url.endsWith('&fife=w800')).toBe(true)
62
- })
63
-
64
- it('appends &fife=w{width} using a custom width', () => {
65
- const url = googleBooksCoverUrl('https://books.google.com/books/content?id=abc&zoom=1', 200)
66
- expect(url.endsWith('&fife=w200')).toBe(true)
67
- })
68
-
69
- it('applies all transformations together', () => {
70
- const url = googleBooksCoverUrl(
71
- 'http://books.google.com/books/content?id=abc&zoom=5&edge=curl',
72
- 400
73
- )
74
- expect(url).toBe('https://books.google.com/books/content?id=abc&zoom=3&fife=w400')
75
- })
76
- })
@@ -1,144 +0,0 @@
1
- import { describe, expect, it, vi } from 'vite-plus/test'
2
-
3
- import { googleBooksCoverUrl, googleBooksMediaType } from './client'
4
- import { normaliseGoogleBooksVolume } from './normalise'
5
- import type { GoogleBooksVolume } from './types'
6
-
7
- // normalise.ts calls googleBooksMediaType()/googleBooksCoverUrl() as Nitro auto-imports;
8
- // stub them with the real (pure, string-building) implementations from client.ts.
9
- vi.stubGlobal('googleBooksMediaType', googleBooksMediaType)
10
- vi.stubGlobal('googleBooksCoverUrl', googleBooksCoverUrl)
11
-
12
- function baseVolume(overrides: Partial<GoogleBooksVolume['volumeInfo']> = {}): GoogleBooksVolume {
13
- return {
14
- id: 'abc123',
15
- selfLink: 'https://www.googleapis.com/books/v1/volumes/abc123',
16
- volumeInfo: {
17
- title: 'Watchmen',
18
- ...overrides,
19
- },
20
- }
21
- }
22
-
23
- describe('normaliseGoogleBooksVolume', () => {
24
- it('prefers extraLarge over all other image sizes', () => {
25
- const record = normaliseGoogleBooksVolume(
26
- baseVolume({
27
- imageLinks: {
28
- smallThumbnail: 's.jpg',
29
- thumbnail: 't.jpg',
30
- small: 'sm.jpg',
31
- medium: 'm.jpg',
32
- large: 'l.jpg',
33
- extraLarge: 'xl.jpg',
34
- },
35
- })
36
- )
37
- expect(record.coverUrl).toBe(googleBooksCoverUrl('xl.jpg'))
38
- })
39
-
40
- it('falls back to large when extraLarge is absent', () => {
41
- const record = normaliseGoogleBooksVolume(
42
- baseVolume({ imageLinks: { large: 'l.jpg', medium: 'm.jpg' } })
43
- )
44
- expect(record.coverUrl).toBe(googleBooksCoverUrl('l.jpg'))
45
- })
46
-
47
- it('falls back to medium when extraLarge/large are absent', () => {
48
- const record = normaliseGoogleBooksVolume(
49
- baseVolume({ imageLinks: { medium: 'm.jpg', small: 'sm.jpg' } })
50
- )
51
- expect(record.coverUrl).toBe(googleBooksCoverUrl('m.jpg'))
52
- })
53
-
54
- it('falls back to small when extraLarge/large/medium are absent', () => {
55
- const record = normaliseGoogleBooksVolume(
56
- baseVolume({ imageLinks: { small: 'sm.jpg', thumbnail: 't.jpg' } })
57
- )
58
- expect(record.coverUrl).toBe(googleBooksCoverUrl('sm.jpg'))
59
- })
60
-
61
- it('falls back to thumbnail when larger sizes are absent', () => {
62
- const record = normaliseGoogleBooksVolume(
63
- baseVolume({ imageLinks: { thumbnail: 't.jpg', smallThumbnail: 's.jpg' } })
64
- )
65
- expect(record.coverUrl).toBe(googleBooksCoverUrl('t.jpg'))
66
- })
67
-
68
- it('falls back to smallThumbnail as the last resort', () => {
69
- const record = normaliseGoogleBooksVolume(
70
- baseVolume({ imageLinks: { smallThumbnail: 's.jpg' } })
71
- )
72
- expect(record.coverUrl).toBe(googleBooksCoverUrl('s.jpg'))
73
- })
74
-
75
- it('leaves coverUrl undefined when imageLinks is absent', () => {
76
- expect(normaliseGoogleBooksVolume(baseVolume()).coverUrl).toBeUndefined()
77
- })
78
-
79
- it('extracts isbn10/isbn13 from industryIdentifiers', () => {
80
- const record = normaliseGoogleBooksVolume(
81
- baseVolume({
82
- industryIdentifiers: [
83
- { type: 'ISBN_10', identifier: '0930289232' },
84
- { type: 'ISBN_13', identifier: '9780930289232' },
85
- ],
86
- })
87
- )
88
- expect(record.identifiers).toMatchObject({
89
- isbn10: '0930289232',
90
- isbn13: '9780930289232',
91
- })
92
- })
93
-
94
- it('leaves isbn10/isbn13 undefined when industryIdentifiers is absent', () => {
95
- const record = normaliseGoogleBooksVolume(baseVolume())
96
- expect(record.identifiers?.isbn10).toBeUndefined()
97
- expect(record.identifiers?.isbn13).toBeUndefined()
98
- })
99
-
100
- it('always sets identifiers.googleBooksId to the volume id', () => {
101
- const record = normaliseGoogleBooksVolume(baseVolume())
102
- expect(record.identifiers?.googleBooksId).toBe('abc123')
103
- })
104
-
105
- it('classifies mediaType via googleBooksMediaType(categories)', () => {
106
- const manga = normaliseGoogleBooksVolume(baseVolume({ categories: ['Manga'] }))
107
- expect(manga.mediaType).toBe('manga')
108
-
109
- const book = normaliseGoogleBooksVolume(baseVolume({ categories: ['Fiction'] }))
110
- expect(book.mediaType).toBe('book')
111
- })
112
-
113
- it('maps authors to creators with the author role', () => {
114
- const record = normaliseGoogleBooksVolume(baseVolume({ authors: ['Alan Moore'] }))
115
- expect(record.creators).toEqual([{ name: 'Alan Moore', role: 'author' }])
116
- })
117
-
118
- it('leaves creators undefined when authors is absent', () => {
119
- expect(normaliseGoogleBooksVolume(baseVolume()).creators).toBeUndefined()
120
- })
121
-
122
- it('prefers canonicalVolumeLink over infoLink for sourceUrl', () => {
123
- const record = normaliseGoogleBooksVolume(
124
- baseVolume({ canonicalVolumeLink: 'canonical.url', infoLink: 'info.url' })
125
- )
126
- expect(record.sourceUrl).toBe('canonical.url')
127
- })
128
-
129
- it('falls back to infoLink when canonicalVolumeLink is absent', () => {
130
- const record = normaliseGoogleBooksVolume(baseVolume({ infoLink: 'info.url' }))
131
- expect(record.sourceUrl).toBe('info.url')
132
- })
133
-
134
- it('stamps lastSyncedAt as a string', () => {
135
- expect(normaliseGoogleBooksVolume(baseVolume()).lastSyncedAt).toEqual(expect.any(String))
136
- })
137
-
138
- it('sets id and provider fields from the volume id', () => {
139
- const record = normaliseGoogleBooksVolume(baseVolume())
140
- expect(record.id).toBe('google-books:volume:abc123')
141
- expect(record.provider).toBe('google-books')
142
- expect(record.providerId).toBe('abc123')
143
- })
144
- })
@@ -1,25 +0,0 @@
1
- import { describe, expect, it } from 'vite-plus/test'
2
-
3
- import { openLibraryCoverUrl } from './client'
4
-
5
- describe('openLibraryCoverUrl', () => {
6
- it('builds the covers.openlibrary.org URL shape', () => {
7
- expect(openLibraryCoverUrl(12345)).toBe('https://covers.openlibrary.org/b/id/12345-L.jpg')
8
- })
9
-
10
- it('defaults to size L when no size is given', () => {
11
- expect(openLibraryCoverUrl(1)).toBe('https://covers.openlibrary.org/b/id/1-L.jpg')
12
- })
13
-
14
- it('supports the S size', () => {
15
- expect(openLibraryCoverUrl(1, 'S')).toBe('https://covers.openlibrary.org/b/id/1-S.jpg')
16
- })
17
-
18
- it('supports the M size', () => {
19
- expect(openLibraryCoverUrl(1, 'M')).toBe('https://covers.openlibrary.org/b/id/1-M.jpg')
20
- })
21
-
22
- it('supports the L size explicitly', () => {
23
- expect(openLibraryCoverUrl(1, 'L')).toBe('https://covers.openlibrary.org/b/id/1-L.jpg')
24
- })
25
- })