diff --git a/src/runtime/components/resolve-name.ts b/src/runtime/components/resolve-name.ts new file mode 100644 index 00000000..84295387 --- /dev/null +++ b/src/runtime/components/resolve-name.ts @@ -0,0 +1,38 @@ +import { computed, reactive, toRaw } from 'vue' +import type { NuxtIconRuntimeOptions } from '../../types' + +const HYPHEN = 45 + +const sortedCollectionsCache = new WeakMap() + +export function getSortedCollections(collections: string[] | null | undefined): string[] { + if (!collections) + return [] + const key = toRaw(collections) + let sorted = sortedCollectionsCache.get(key) + if (!sorted) { + const sort = (source: string[]) => [...source].sort((a, b) => b.length - a.length) + // App config is cloned per request on the server, but reactive and updated in place on the client (HMR, `updateAppConfig`) + sorted = import.meta.client ? computed(() => sort(reactive(key))) : { value: sort(key) } + sortedCollectionsCache.set(key, sorted) + } + return sorted.value +} + +export function resolveName(name: string, options: Pick): string { + const bare = name.startsWith(options.cssSelectorPrefix) + ? name.slice(options.cssSelectorPrefix.length) + : name + const resolved = options.aliases?.[bare] || bare + + // Disambiguate collection + // e.g. `simple-icons-github` -> `simple-icons:github` + if (!resolved.includes(':')) { + const collection = getSortedCollections(options.collections) + .find(c => resolved.charCodeAt(c.length) === HYPHEN && resolved.startsWith(c)) + return collection + ? collection + ':' + resolved.slice(collection.length + 1) + : resolved + } + return resolved +} diff --git a/src/runtime/components/shared.ts b/src/runtime/components/shared.ts index 3b27a05c..4e21a8a2 100644 --- a/src/runtime/components/shared.ts +++ b/src/runtime/components/shared.ts @@ -4,6 +4,7 @@ import type { IconifyIcon } from '@iconify/types' import type { NuxtIconRuntimeOptions } from '../../types' import { useAppConfig } from '#imports' import { init as initClientBundle } from '#build/nuxt-icon-client-bundle' +import { resolveName } from './resolve-name' export { initClientBundle } @@ -47,25 +48,8 @@ export async function loadIcon(name: string, timeout: number): Promise string) { const options = useAppConfig().icon as NuxtIconRuntimeOptions - const collections = (options.collections || []).sort((a, b) => b.length - a.length) - return computed(() => { - const name = getName() - const bare = name.startsWith(options.cssSelectorPrefix) - ? name.slice(options.cssSelectorPrefix.length) - : name - const resolved = options.aliases?.[bare] || bare - - // Disambiguate collection - // e.g. `simple-icons-github` -> `simple-icons:github` - if (!resolved.includes(':')) { - const collection = collections.find(c => resolved.startsWith(c + '-')) - return collection - ? collection + ':' + resolved.slice(collection.length + 1) - : resolved - } - return resolved - }) + return computed(() => resolveName(getName(), options)) } export function resolveCustomizeFn( diff --git a/test/resolve-name.test.ts b/test/resolve-name.test.ts new file mode 100644 index 00000000..61898fbd --- /dev/null +++ b/test/resolve-name.test.ts @@ -0,0 +1,82 @@ +import { computed, reactive } from 'vue' +import { describe, expect, it } from 'vitest' +import { getSortedCollections, resolveName } from '../src/runtime/components/resolve-name' + +function createOptions(collections: string[] | null, aliases: Record = {}) { + return { cssSelectorPrefix: 'i-', aliases, collections } +} + +describe('resolveName', () => { + it('keeps names that already contain a collection separator', () => { + expect(resolveName('i-simple-icons:github', createOptions(['simple-icons']))).toBe('simple-icons:github') + }) + + it('resolves a bare name against the longest matching collection', () => { + const options = createOptions(['simple', 'simple-icons', 'si']) + expect(resolveName('i-simple-icons-github', options)).toBe('simple-icons:github') + expect(resolveName('simple-icons-github', options)).toBe('simple-icons:github') + expect(resolveName('i-simple-foo', options)).toBe('simple:foo') + expect(resolveName('i-si-foo', options)).toBe('si:foo') + }) + + it('requires a hyphen after the collection name', () => { + const options = createOptions(['simple', 'si']) + expect(resolveName('i-simpleicons', options)).toBe('simpleicons') + expect(resolveName('i-si', options)).toBe('si') + }) + + it('resolves aliases before matching collections', () => { + expect(resolveName('i-github', createOptions(['simple-icons'], { github: 'simple-icons-github' }))).toBe('simple-icons:github') + }) + + it('keeps bare names without a matching collection', () => { + expect(resolveName('i-unknown-icon', createOptions(['simple-icons']))).toBe('unknown-icon') + expect(resolveName('i-unknown-icon', createOptions(null))).toBe('unknown-icon') + }) + + it('does not reorder the configured collections', () => { + const collections = ['simple', 'simple-icons', 'si'] + resolveName('i-simple-icons-github', createOptions(collections)) + expect(collections).toEqual(['simple', 'simple-icons', 'si']) + }) + + it('updates computed names when reactive collections change in place', () => { + const options = reactive(createOptions(['simple', 'foo'])) + const first = computed(() => resolveName('i-simple-icons-github', options)) + const second = computed(() => resolveName('i-simple-icons-github', options)) + expect(first.value).toBe('simple:icons-github') + expect(second.value).toBe('simple:icons-github') + + options.collections![1] = 'simple-icons' + expect(first.value).toBe('simple-icons:github') + expect(second.value).toBe('simple-icons:github') + }) + + it('updates computed names when collections first resolved as a plain array change', () => { + const collections = ['simple', 'foo'] + expect(resolveName('i-simple-icons-github', createOptions(collections))).toBe('simple:icons-github') + + const options = reactive(createOptions(collections)) + const name = computed(() => resolveName('i-simple-icons-github', options)) + expect(name.value).toBe('simple:icons-github') + + options.collections![1] = 'simple-icons' + expect(name.value).toBe('simple-icons:github') + }) + + it('updates computed names when reactive collections grow', () => { + const options = reactive(createOptions(['simple'])) + const name = computed(() => resolveName('i-simple-icons-github', options)) + expect(name.value).toBe('simple:icons-github') + + options.collections!.push('simple-icons') + expect(name.value).toBe('simple-icons:github') + }) +}) + +describe('getSortedCollections', () => { + it('sorts each collections array once', () => { + const collections = ['simple', 'simple-icons'] + expect(getSortedCollections(collections)).toBe(getSortedCollections(collections)) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 37fb2828..58825fa9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ + define: { + 'import.meta.client': 'true', + }, test: { exclude: ['playgrounds/**', 'node_modules/**'], },