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,24 @@
1
+ export default defineEventHandler(async () => {
2
+ const providers = getProviders()
3
+
4
+ const results = await Promise.allSettled(
5
+ providers.map(async (p) => {
6
+ await p.search({ query: 'test', limit: 1 })
7
+ return { id: p.id, label: p.label, ok: true, error: null }
8
+ }),
9
+ )
10
+
11
+ return results.map((outcome, i) => {
12
+ const p = providers[i]!
13
+ if (outcome.status === 'fulfilled') {
14
+ return { id: p.id, label: p.label, ok: true, error: null }
15
+ }
16
+ const err = outcome.reason
17
+ return {
18
+ id: p.id,
19
+ label: p.label,
20
+ ok: false,
21
+ error: err instanceof Error ? err.message : String(err),
22
+ }
23
+ })
24
+ })
@@ -1,4 +1,4 @@
1
- import type { MetadataSyncInput, MetadataProviderId } from '#layers/metadata/shared/types'
1
+ import type { MetadataProviderId, MetadataSyncInput } from '#layers/metadata/shared/types'
2
2
 
3
3
  // fallow-ignore-next-line complexity
4
4
  export default defineEventHandler(async (event) => {
@@ -10,8 +10,13 @@ export default defineEventHandler(async (event) => {
10
10
  }
11
11
 
12
12
  const provider = getProvider(providerName as MetadataProviderId)
13
- if (!provider) throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
14
- if (!provider.sync) throw createError({ statusCode: 400, message: `Provider '${providerName}' does not support sync` })
13
+ if (!provider)
14
+ throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
15
+ if (!provider.sync)
16
+ throw createError({
17
+ statusCode: 400,
18
+ message: `Provider '${providerName}' does not support sync`,
19
+ })
15
20
 
16
21
  const cacheKey = makeCacheKey('metadata', providerName, resourceType ?? 'item', providerId)
17
22
 
@@ -20,7 +25,12 @@ export default defineEventHandler(async (event) => {
20
25
  if (cached) return { synced: false, record: cached.normalised }
21
26
  }
22
27
 
23
- const result = await provider.sync({ provider: providerName as MetadataProviderId, providerId, resourceType, force })
28
+ const result = await provider.sync({
29
+ provider: providerName as MetadataProviderId,
30
+ providerId,
31
+ ...(resourceType && { resourceType }),
32
+ ...(force !== undefined && { force }),
33
+ })
24
34
  const now = new Date().toISOString()
25
35
 
26
36
  await setCacheRecord(cacheKey, {
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { hashQuery, makeCacheKey } from './cache'
4
+
5
+ describe('makeCacheKey', () => {
6
+ it('joins parts with ":"', () => {
7
+ expect(makeCacheKey('metadata', 'comicvine', 'issue', '1000')).toBe(
8
+ 'metadata:comicvine:issue:1000'
9
+ )
10
+ })
11
+
12
+ it('handles a single part', () => {
13
+ expect(makeCacheKey('metadata')).toBe('metadata')
14
+ })
15
+
16
+ it('handles no parts', () => {
17
+ expect(makeCacheKey()).toBe('')
18
+ })
19
+ })
20
+
21
+ describe('hashQuery', () => {
22
+ it('is deterministic for the same input', () => {
23
+ expect(hashQuery('watchmen')).toBe(hashQuery('watchmen'))
24
+ })
25
+
26
+ it('differs for different inputs', () => {
27
+ expect(hashQuery('watchmen')).not.toBe(hashQuery('v for vendetta'))
28
+ })
29
+
30
+ it('handles an empty string', () => {
31
+ expect(hashQuery('')).toBe('0')
32
+ })
33
+
34
+ it('returns a non-negative base36 string', () => {
35
+ const hash = hashQuery('some query with lots of characters to hash')
36
+ expect(hash).toEqual(expect.any(String))
37
+ expect(hash.startsWith('-')).toBe(false)
38
+ })
39
+ })
@@ -3,6 +3,10 @@ import type { MetadataCacheRecord } from '#layers/metadata/shared/types'
3
3
  // Cache keys: metadata:{provider}:{resourceType}:{providerId}
4
4
  // metadata-search:{provider}:{mediaType}:{queryHash}
5
5
 
6
+ // unstorage isn't a direct dependency, so derive its StorageValue type from
7
+ // useStorage's own signature rather than importing the package by name.
8
+ type StorageValue = Parameters<ReturnType<typeof useStorage>['setItem']>[1]
9
+
6
10
  export function makeCacheKey(...parts: string[]): string {
7
11
  return parts.join(':')
8
12
  }
@@ -12,17 +16,25 @@ export async function getCacheRecord(key: string): Promise<MetadataCacheRecord |
12
16
  return (await storage.getItem<MetadataCacheRecord>(key)) ?? null
13
17
  }
14
18
 
15
- export async function setCacheRecord(key: string, record: MetadataCacheRecord, ttl?: number): Promise<void> {
19
+ export async function setCacheRecord(
20
+ key: string,
21
+ record: MetadataCacheRecord,
22
+ ttl?: number
23
+ ): Promise<void> {
16
24
  const storage = useStorage('metadata')
17
25
  await storage.setItem(key, record, ttl ? { ttl } : undefined)
18
26
  }
19
27
 
20
- export async function getSearchCache<T>(key: string): Promise<T | null> {
28
+ export async function getSearchCache<T extends StorageValue>(key: string): Promise<T | null> {
21
29
  const storage = useStorage('metadata')
22
30
  return (await storage.getItem<T>(key)) ?? null
23
31
  }
24
32
 
25
- export async function setSearchCache<T>(key: string, value: T, ttl = 3600): Promise<void> {
33
+ export async function setSearchCache<T extends StorageValue>(
34
+ key: string,
35
+ value: T,
36
+ ttl = 3600
37
+ ): Promise<void> {
26
38
  const storage = useStorage('metadata')
27
39
  await storage.setItem(key, value, { ttl })
28
40
  }
@@ -2,7 +2,7 @@ export class MetadataProviderError extends Error {
2
2
  constructor(
3
3
  public readonly provider: string,
4
4
  message: string,
5
- public readonly cause?: unknown,
5
+ public override readonly cause?: unknown
6
6
  ) {
7
7
  super(`[metadata:${provider}] ${message}`)
8
8
  this.name = 'MetadataProviderError'
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { getProvider, getProviders, registerProvider } from './provider-registry'
4
+ import type { MetadataProvider } from '#layers/metadata/shared/types'
5
+
6
+ function makeProvider(overrides: Partial<MetadataProvider> = {}): MetadataProvider {
7
+ return {
8
+ id: 'comicvine',
9
+ label: 'Comic Vine',
10
+ mediaTypes: ['comic'],
11
+ search: async () => [],
12
+ lookup: async () => null,
13
+ ...overrides,
14
+ }
15
+ }
16
+
17
+ // NOTE: _registry is a module-level Map singleton shared across every test in this file
18
+ // (vitest isolates modules per test *file*, not per test). Tests below account for
19
+ // accumulation by asserting deltas/membership rather than absolute counts, and by
20
+ // checking unregistered ids before registering them.
21
+
22
+ describe('provider-registry', () => {
23
+ it('returns null for an id that has not been registered yet', () => {
24
+ expect(getProvider('tmdb')).toBeNull()
25
+ })
26
+
27
+ it('registers a provider and retrieves it by id', () => {
28
+ const provider = makeProvider({ id: 'comicvine' })
29
+ registerProvider(provider)
30
+ expect(getProvider('comicvine')).toBe(provider)
31
+ })
32
+
33
+ it('includes registered providers in getProviders()', () => {
34
+ const before = getProviders().length
35
+ registerProvider(makeProvider({ id: 'openlibrary', label: 'Open Library' }))
36
+ const after = getProviders()
37
+ expect(after.length).toBe(before + 1)
38
+ expect(after.map((p) => p.id)).toContain('openlibrary')
39
+ })
40
+
41
+ it('overwrites the existing entry when registering the same id again', () => {
42
+ const countBefore = getProviders().length
43
+ const first = makeProvider({ id: 'google-books', label: 'First' })
44
+ const second = makeProvider({ id: 'google-books', label: 'Second' })
45
+
46
+ registerProvider(first)
47
+ expect(getProviders().length).toBe(countBefore + 1)
48
+
49
+ registerProvider(second)
50
+ expect(getProviders().length).toBe(countBefore + 1)
51
+ expect(getProvider('google-books')).toBe(second)
52
+ expect(getProvider('google-books')).not.toBe(first)
53
+ })
54
+ })
@@ -0,0 +1,198 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { hashQuery, makeCacheKey } from './cache'
4
+ import type {
5
+ MetadataProvider,
6
+ MetadataSearchInput,
7
+ MetadataSearchResult,
8
+ } from '#layers/metadata/shared/types'
9
+
10
+ // search.ts calls getProviders()/makeCacheKey()/hashQuery()/getSearchCache()/setSearchCache()
11
+ // as Nitro auto-imports (sibling server/utils files). Stub the pure cache helpers with their
12
+ // real implementations, and control provider/cache lookups directly via mocks.
13
+ let mockProviders: MetadataProvider[] = []
14
+ const getProvidersMock = vi.fn(() => mockProviders)
15
+ const getSearchCacheMock = vi.fn(async () => null as MetadataSearchResult[] | null)
16
+ const setSearchCacheMock = vi.fn(async () => undefined)
17
+
18
+ vi.stubGlobal('getProviders', getProvidersMock)
19
+ vi.stubGlobal('makeCacheKey', makeCacheKey)
20
+ vi.stubGlobal('hashQuery', hashQuery)
21
+ vi.stubGlobal('getSearchCache', getSearchCacheMock)
22
+ vi.stubGlobal('setSearchCache', setSearchCacheMock)
23
+
24
+ const { searchMetadata } = await import('./search')
25
+
26
+ function makeResult(overrides: Partial<MetadataSearchResult> = {}): MetadataSearchResult {
27
+ return {
28
+ id: 'comicvine:issue:1',
29
+ provider: 'comicvine',
30
+ providerId: '1',
31
+ mediaType: 'comic',
32
+ title: 'Watchmen',
33
+ ...overrides,
34
+ }
35
+ }
36
+
37
+ function makeProvider(overrides: Partial<MetadataProvider> = {}): MetadataProvider {
38
+ return {
39
+ id: 'comicvine',
40
+ label: 'Comic Vine',
41
+ mediaTypes: ['comic'],
42
+ search: async () => [],
43
+ lookup: async () => null,
44
+ ...overrides,
45
+ }
46
+ }
47
+
48
+ beforeEach(() => {
49
+ mockProviders = []
50
+ getProvidersMock.mockClear()
51
+ getSearchCacheMock.mockClear()
52
+ getSearchCacheMock.mockResolvedValue(null)
53
+ setSearchCacheMock.mockClear()
54
+ })
55
+
56
+ function baseInput(overrides: Partial<MetadataSearchInput> = {}): MetadataSearchInput {
57
+ return { query: 'watchmen', ...overrides }
58
+ }
59
+
60
+ describe('searchMetadata', () => {
61
+ it('targets only providers named in input.providers', async () => {
62
+ const a = vi.fn(async () => [makeResult({ id: 'a-1' })])
63
+ const b = vi.fn(async () => [makeResult({ id: 'b-1' })])
64
+ mockProviders = [
65
+ makeProvider({ id: 'comicvine', search: a }),
66
+ makeProvider({ id: 'openlibrary', mediaTypes: ['book'], search: b }),
67
+ ]
68
+
69
+ await searchMetadata(baseInput({ providers: ['comicvine'] }))
70
+
71
+ expect(a).toHaveBeenCalledTimes(1)
72
+ expect(b).not.toHaveBeenCalled()
73
+ })
74
+
75
+ it('targets providers by mediaType when providers[] is not given', async () => {
76
+ const comic = vi.fn(async () => [makeResult()])
77
+ const book = vi.fn(async () => [makeResult({ mediaType: 'book' })])
78
+ mockProviders = [
79
+ makeProvider({ id: 'comicvine', mediaTypes: ['comic'], search: comic }),
80
+ makeProvider({ id: 'openlibrary', mediaTypes: ['book'], search: book }),
81
+ ]
82
+
83
+ await searchMetadata(baseInput({ mediaType: 'comic' }))
84
+
85
+ expect(comic).toHaveBeenCalledTimes(1)
86
+ expect(book).not.toHaveBeenCalled()
87
+ })
88
+
89
+ it('targets all providers when neither providers[] nor mediaType is given', async () => {
90
+ const comic = vi.fn(async () => [makeResult()])
91
+ const book = vi.fn(async () => [makeResult({ mediaType: 'book' })])
92
+ mockProviders = [
93
+ makeProvider({ id: 'comicvine', mediaTypes: ['comic'], search: comic }),
94
+ makeProvider({ id: 'openlibrary', mediaTypes: ['book'], search: book }),
95
+ ]
96
+
97
+ await searchMetadata(baseInput())
98
+
99
+ expect(comic).toHaveBeenCalledTimes(1)
100
+ expect(book).toHaveBeenCalledTimes(1)
101
+ })
102
+
103
+ it('returns [] when no providers match the given targets', async () => {
104
+ mockProviders = [makeProvider({ id: 'comicvine', mediaTypes: ['comic'] })]
105
+
106
+ const result = await searchMetadata(baseInput({ mediaType: 'movie' }))
107
+
108
+ expect(result).toEqual([])
109
+ expect(getSearchCacheMock).not.toHaveBeenCalled()
110
+ })
111
+
112
+ it('returns [] when providers[] names an id with no registered provider', async () => {
113
+ mockProviders = [makeProvider({ id: 'comicvine' })]
114
+
115
+ const result = await searchMetadata(baseInput({ providers: ['tmdb'] }))
116
+
117
+ expect(result).toEqual([])
118
+ })
119
+
120
+ it('keeps results from other providers when one provider rejects', async () => {
121
+ const ok = vi.fn(async () => [makeResult({ id: 'ok-1', title: 'Watchmen' })])
122
+ const failing = vi.fn(async () => {
123
+ throw new Error('boom')
124
+ })
125
+ mockProviders = [
126
+ makeProvider({ id: 'comicvine', search: ok }),
127
+ makeProvider({ id: 'openlibrary', mediaTypes: ['book'], search: failing }),
128
+ ]
129
+
130
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
131
+
132
+ const result = await searchMetadata(baseInput())
133
+
134
+ expect(result).toHaveLength(1)
135
+ expect(result[0]!.id).toBe('ok-1')
136
+ expect(consoleErrorSpy).toHaveBeenCalled()
137
+
138
+ consoleErrorSpy.mockRestore()
139
+ })
140
+
141
+ it('deduplicates results by mediaType:title(lowercase):year', async () => {
142
+ const a = vi.fn(async () => [
143
+ makeResult({ id: 'a-1', title: 'Watchmen', mediaType: 'comic', publishedAt: '1986-09-01' }),
144
+ ])
145
+ const b = vi.fn(async () => [
146
+ makeResult({ id: 'b-1', title: 'WATCHMEN', mediaType: 'comic', publishedAt: '1986-12-25' }),
147
+ ])
148
+ mockProviders = [
149
+ makeProvider({ id: 'comicvine', search: a }),
150
+ makeProvider({ id: 'openlibrary', mediaTypes: ['comic'], search: b }),
151
+ ]
152
+
153
+ const result = await searchMetadata(baseInput())
154
+
155
+ expect(result).toHaveLength(1)
156
+ expect(result[0]!.id).toBe('a-1')
157
+ })
158
+
159
+ it('does not dedupe results with different years', async () => {
160
+ const a = vi.fn(async () => [
161
+ makeResult({ id: 'a-1', title: 'Watchmen', publishedAt: '1986-09-01' }),
162
+ ])
163
+ const b = vi.fn(async () => [
164
+ makeResult({ id: 'b-1', title: 'Watchmen', publishedAt: '2019-10-20' }),
165
+ ])
166
+ mockProviders = [
167
+ makeProvider({ id: 'comicvine', search: a }),
168
+ makeProvider({ id: 'openlibrary', mediaTypes: ['comic'], search: b }),
169
+ ]
170
+
171
+ const result = await searchMetadata(baseInput())
172
+
173
+ expect(result).toHaveLength(2)
174
+ })
175
+
176
+ it('short-circuits on a cache hit without calling provider search functions', async () => {
177
+ const cached = [makeResult({ id: 'cached-1' })]
178
+ getSearchCacheMock.mockResolvedValue(cached)
179
+ const search = vi.fn(async () => [makeResult({ id: 'live-1' })])
180
+ mockProviders = [makeProvider({ id: 'comicvine', search })]
181
+
182
+ const result = await searchMetadata(baseInput())
183
+
184
+ expect(result).toBe(cached)
185
+ expect(search).not.toHaveBeenCalled()
186
+ expect(setSearchCacheMock).not.toHaveBeenCalled()
187
+ })
188
+
189
+ it('writes deduped results to the cache after a live search', async () => {
190
+ const search = vi.fn(async () => [makeResult({ id: 'live-1' })])
191
+ mockProviders = [makeProvider({ id: 'comicvine', search })]
192
+
193
+ const result = await searchMetadata(baseInput())
194
+
195
+ expect(setSearchCacheMock).toHaveBeenCalledTimes(1)
196
+ expect(setSearchCacheMock).toHaveBeenCalledWith(expect.any(String), result)
197
+ })
198
+ })
@@ -13,7 +13,7 @@ export async function searchMetadata(input: MetadataSearchInput): Promise<Metada
13
13
  'metadata-search',
14
14
  targets.map((p) => p.id).join('+'),
15
15
  input.mediaType ?? 'any',
16
- hashQuery(`${input.query}:${input.limit ?? 10}`),
16
+ hashQuery(`${input.query}:${input.limit ?? 10}`)
17
17
  )
18
18
 
19
19
  const cached = await getSearchCache<MetadataSearchResult[]>(cacheKey)
@@ -22,8 +22,13 @@ export async function searchMetadata(input: MetadataSearchInput): Promise<Metada
22
22
  const settled = await Promise.allSettled(targets.map((p) => p.search(input)))
23
23
 
24
24
  const results: MetadataSearchResult[] = []
25
- for (const outcome of settled) {
26
- if (outcome.status === 'fulfilled') results.push(...outcome.value)
25
+ for (let i = 0; i < settled.length; i++) {
26
+ const outcome = settled[i]!
27
+ if (outcome.status === 'fulfilled') {
28
+ results.push(...outcome.value)
29
+ } else {
30
+ console.error(`[metadata] provider "${targets[i]!.id}" search failed:`, outcome.reason)
31
+ }
27
32
  }
28
33
 
29
34
  const deduped = deduplicateResults(results)
@@ -34,7 +39,7 @@ export async function searchMetadata(input: MetadataSearchInput): Promise<Metada
34
39
  function deduplicateResults(results: MetadataSearchResult[]): MetadataSearchResult[] {
35
40
  const seen = new Set<string>()
36
41
  return results.filter((r) => {
37
- const key = `${r.title.toLowerCase()}:${r.publishedAt?.slice(0, 4) ?? ''}`
42
+ const key = `${r.mediaType}:${r.title.toLowerCase()}:${r.publishedAt?.slice(0, 4) ?? ''}`
38
43
  if (seen.has(key)) return false
39
44
  seen.add(key)
40
45
  return true
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { resolveSiteConfig } from './site'
4
+
5
+ describe('resolveSiteConfig', () => {
6
+ it('defaults title, subtitle, and description to empty values when omitted', () => {
7
+ expect(resolveSiteConfig({})).toEqual({
8
+ title: '',
9
+ titleWords: [''],
10
+ subtitle: '',
11
+ subtitleWords: [''],
12
+ description: '',
13
+ })
14
+ })
15
+
16
+ it('splits the title and subtitle into words', () => {
17
+ expect(
18
+ resolveSiteConfig({ title: 'Nuxt Layers', subtitle: 'Composable Vue apps' })
19
+ ).toEqual({
20
+ title: 'Nuxt Layers',
21
+ titleWords: ['Nuxt', 'Layers'],
22
+ subtitle: 'Composable Vue apps',
23
+ subtitleWords: ['Composable', 'Vue', 'apps'],
24
+ description: '',
25
+ })
26
+ })
27
+
28
+ it('passes description through unchanged when provided', () => {
29
+ expect(resolveSiteConfig({ description: 'A monorepo of Nuxt layers' })).toEqual({
30
+ title: '',
31
+ titleWords: [''],
32
+ subtitle: '',
33
+ subtitleWords: [''],
34
+ description: 'A monorepo of Nuxt layers',
35
+ })
36
+ })
37
+
38
+ it('handles a single-word title with no spaces', () => {
39
+ expect(resolveSiteConfig({ title: 'Solo' }).titleWords).toEqual(['Solo'])
40
+ })
41
+ })
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { SiteConfig } from '#layers/core/app/types/site'
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: 'OgImageBasic',
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: 'OgImageBasic',
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
+ })
@@ -0,0 +1,56 @@
1
+ import twColors from 'tailwindcss/colors'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { buildAccentCSS } from './accent-css'
5
+
6
+ const ACCENTS = [
7
+ 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal',
8
+ 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose',
9
+ ] as const
10
+
11
+ const SHADES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const
12
+
13
+ describe('buildAccentCSS', () => {
14
+ const css = buildAccentCSS()
15
+
16
+ it('emits one html[data-theme-colour] rule per accent', () => {
17
+ for (const accent of ACCENTS) {
18
+ expect(css).toContain(`html[data-theme-colour="${accent}"]{`)
19
+ }
20
+ expect(css.match(/html\[data-theme-colour="/g)).toHaveLength(ACCENTS.length)
21
+ })
22
+
23
+ it('never emits the literal string "undefined"', () => {
24
+ expect(css).not.toContain('undefined')
25
+ })
26
+
27
+ it('includes every shade for the primary palette of each accent', () => {
28
+ for (const accent of ACCENTS) {
29
+ const ruleMatch = css.match(
30
+ new RegExp(`html\\[data-theme-colour="${accent}"\\]\\{([^}]*)\\}`)
31
+ )
32
+ expect(ruleMatch).not.toBeNull()
33
+ const rule = ruleMatch?.[1] ?? ''
34
+
35
+ for (const shade of SHADES) {
36
+ expect(rule).toContain(`--ui-color-primary-${shade}:`)
37
+ }
38
+ }
39
+ })
40
+
41
+ it('uses the actual tailwind colour values for the primary palette', () => {
42
+ const redPalette = twColors.red as unknown as Record<number, string>
43
+ expect(css).toContain(`--ui-color-primary-500:${redPalette[500]};`)
44
+ expect(css).toContain(`--ui-color-primary-50:${redPalette[50]};`)
45
+ })
46
+
47
+ it('includes secondary and info palettes derived from the accent map', () => {
48
+ // blue -> secondary: indigo, info: sky
49
+ const indigoPalette = twColors.indigo as unknown as Record<number, string>
50
+ const skyPalette = twColors.sky as unknown as Record<number, string>
51
+
52
+ const blueRule = css.match(/html\[data-theme-colour="blue"\]\{([^}]*)\}/)?.[1] ?? ''
53
+ expect(blueRule).toContain(`--ui-color-secondary-500:${indigoPalette[500]};`)
54
+ expect(blueRule).toContain(`--ui-color-info-500:${skyPalette[500]};`)
55
+ })
56
+ })
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { buildThemeInitScript } from './fouc-config'
4
+
5
+ describe('buildThemeInitScript', () => {
6
+ const script = buildThemeInitScript('blue')
7
+
8
+ it('wraps the whole script in a try/catch IIFE', () => {
9
+ expect(script.startsWith('(function(){try{')).toBe(true)
10
+ expect(script.endsWith('}catch(e){}})()')).toBe(true)
11
+ })
12
+
13
+ it('captures document.documentElement as h', () => {
14
+ expect(script).toContain('var h=document.documentElement;')
15
+ })
16
+
17
+ it('reads theme-colour from localStorage and falls back to the default accent', () => {
18
+ expect(script).toContain(
19
+ "var c=localStorage.getItem('theme-colour');h.setAttribute('data-theme-colour',c?JSON.parse(c):'blue');"
20
+ )
21
+ })
22
+
23
+ it('uses the provided default accent verbatim, not a hardcoded one', () => {
24
+ const other = buildThemeInitScript('rose')
25
+ expect(other).toContain("h.setAttribute('data-theme-colour',c?JSON.parse(c):'rose');")
26
+ expect(other).not.toContain("'blue'")
27
+ })
28
+
29
+ it('emits a JSON-parsed tri-state block for theme-contrast', () => {
30
+ expect(script).toContain("var ct=localStorage.getItem('theme-contrast');")
31
+ expect(script).toContain("var ctv=ct?JSON.parse(ct):'system';")
32
+ expect(script).toContain("if(ctv==='on'){h.setAttribute('data-theme-contrast','high')}")
33
+ expect(script).toContain("else if(ctv==='off'){h.setAttribute('data-theme-contrast','standard')}")
34
+ expect(script).toContain(
35
+ "else{h.setAttribute('data-theme-contrast',(window.matchMedia&&window.matchMedia('(prefers-contrast:more)').matches)?'high':'standard')}"
36
+ )
37
+ })
38
+
39
+ it('emits a JSON-parsed tri-state block for theme-motion', () => {
40
+ expect(script).toContain("var m=localStorage.getItem('theme-motion');")
41
+ expect(script).toContain("if(mv==='on'){h.setAttribute('data-theme-motion','reduced')}")
42
+ expect(script).toContain("else if(mv==='off'){h.setAttribute('data-theme-motion','full')}")
43
+ expect(script).toContain(
44
+ "window.matchMedia('(prefers-reduced-motion:reduce)').matches)?'reduced':'full'"
45
+ )
46
+ })
47
+
48
+ it('emits a JSON-parsed tri-state block for theme-transparency', () => {
49
+ expect(script).toContain("var t=localStorage.getItem('theme-transparency');")
50
+ expect(script).toContain("if(tv==='on'){h.setAttribute('data-theme-transparency','reduced')}")
51
+ expect(script).toContain("else if(tv==='off'){h.setAttribute('data-theme-transparency','full')}")
52
+ expect(script).toContain(
53
+ "window.matchMedia('(prefers-reduced-transparency:reduce)').matches)?'reduced':'full'"
54
+ )
55
+ })
56
+
57
+ it('emits a plain (non-JSON) tri-state block for theme-mode using dark/light conditions', () => {
58
+ expect(script).toContain("var dm=localStorage.getItem('theme-mode');")
59
+ expect(script).toContain("var dmv=dm||'system';")
60
+ expect(script).toContain("if(dmv==='dark'){h.setAttribute('data-theme-mode','dark')}")
61
+ expect(script).toContain("else if(dmv==='light'){h.setAttribute('data-theme-mode','light')}")
62
+ expect(script).toContain(
63
+ "window.matchMedia('(prefers-color-scheme:dark)').matches)?'dark':'light'"
64
+ )
65
+ })
66
+
67
+ it('does not JSON.parse the theme-mode value (jsonParsed: false)', () => {
68
+ expect(script).not.toContain("JSON.parse(dm)")
69
+ })
70
+
71
+ it('produces valid, evaluable JavaScript', () => {
72
+ expect(() => new Function(script)).not.toThrow()
73
+ })
74
+ })