Source: packages/assets/src/resolver/Resolver.ts

import { convertToList } from '../utils/convertToList';
import { createStringVariations } from '../utils/createStringVariations';
import { isSingleItem } from '../utils/isSingleItem';
import { getBaseUrl, makeAbsoluteUrl } from '../utils/url/makeAbsoluteUrl';
import type { ResolveAsset, PreferOrder, ResolveURLParser, ResolverManifest, ResolverBundle } from './types';

/**
 * A class that is responsible for resolving mapping asset URLs to keys.
 * At its most basic it can be used for Aliases:
 *
 * ```
 * resolver.add('foo', 'bar');
 * resolver.resolveUrl('foo') // => 'bar'
 * ```
 *
 * It can also be used to resolve the most appropriate asset for a given URL:
 *
 * ```
 *  resolver.prefer({
 *      params:{
 *          format:'webp',
 *          resolution: 2,
 *      }
 *  })
 *
 *  resolver.add('foo', ['bar@2x.webp', 'bar@2x.png', 'bar.webp', 'bar.png']);
 *
 *  resolver.resolveUrl('foo') // => 'bar@2x.webp'
 * ```
 * Other features include:
 * - Ability to process a manifest file to get the correct understanding of how to resolve all assets
 * - Ability to add custom parsers for specific file types
 * - Ability to add custom prefer rules
 *
 * This class only cares about the URL, not the loading of the asset itself.
 *
 * It is not intended that this class is created by developers - its part of the Asset class
 * This is the third major system of PixiJS' main Assets class
 * @memberof PIXI
 */
export class Resolver
{
    private _assetMap: Record<string, ResolveAsset[]> = {};
    private _preferredOrder: PreferOrder[] = [];
    private _parsers: ResolveURLParser[] = [];

    private _resolverHash: Record<string, ResolveAsset> = {};
    private _basePath: string;
    private _manifest: ResolverManifest;
    private _bundles: Record<string, string[]> = {};

    /**
     * Let the resolver know which assets you prefer to use when resolving assets.
     * Multiple prefer user defined rules can be added.
     * @example
     * resolver.prefer({
     *     // first look for something with the correct format, and then then correct resolution
     *     priority: ['format', 'resolution'],
     *     params:{
     *         format:'webp', // prefer webp images
     *         resolution: 2, // prefer a resolution of 2
     *     }
     * })
     * resolver.add('foo', ['bar@2x.webp', 'bar@2x.png', 'bar.webp', 'bar.png']);
     * resolver.resolveUrl('foo') // => 'bar@2x.webp'
     * @param preferOrders - the prefer options
     */
    public prefer(...preferOrders: PreferOrder[]): void
    {
        preferOrders.forEach((prefer) =>
        {
            this._preferredOrder.push(prefer);

            if (!prefer.priority)
            {
                // generate the priority based on the order of the object
                prefer.priority = Object.keys(prefer.params);
            }
        });

        this._resolverHash = {};
    }

    /**
     * Set the base path to append to all urls when resolving
     * @example
     * resolver.basePath = 'https://home.com/';
     * resolver.add('foo', 'bar.ong');
     * resolver.resolveUrl('foo', 'bar.png'); // => 'https://home.com/bar.png'
     * @param basePath - the base path to use
     */
    public set basePath(basePath: string)
    {
        this._basePath = getBaseUrl(basePath);
    }

    public get basePath(): string
    {
        return this._basePath;
    }

    /**
     * All the active URL parsers that help the parser to extract information and create
     * an asset object-based on parsing the URL itself.
     *
     * Can be added using the extensions API
     * @example
     * resolver.add('foo', [
     *    {
     *      resolution:2,
     *      format:'png'
     *      src: 'image@2x.png'
     *    },
     *    {
     *      resolution:1,
     *      format:'png'
     *      src: 'image.png'
     *    }
     * ]);
     *
     * // with a url parser the information such as resolution and file format could extracted from the url itself:
     * extensions.add({
     *     extension: ExtensionType.ResolveParser,
     *     test: loadTextures.test, // test if url ends in an image
     *     parse: (value: string) =>
     *     ({
     *         resolution: parseFloat(settings.RETINA_PREFIX.exec(value)?.[1] ?? '1'),
     *         format: value.split('.').pop(),
     *         src: value,
     *     }),
     * });
     *
     * // now resolution and format can be extracted from the url
     * resolver.add('foo', [
     *    'image@2x.png'
     *    'image.png'
     * ]);
     * @
     */
    public get parsers(): ResolveURLParser[]
    {
        return this._parsers;
    }

    /** Used for testing, this resets the resolver to its initial state */
    public reset(): void
    {
        this._preferredOrder = [];

        this._resolverHash = {};
        this._assetMap = {};
        this._basePath = null;
        this._manifest = null;
    }

    /**
     * Add a manifest to the asset resolver. This is a nice way to add all the asset information in one go.
     * generally a manifest would be built using a tool.
     * @param manifest - the manifest to add to the resolver
     */
    public addManifest(manifest: ResolverManifest): void
    {
        if (this._manifest)
        {
            // #if _DEBUG
            console.warn('[Resolver] Manifest already exists, this will be overwritten');
            // #endif
        }

        this._manifest = manifest;

        manifest.bundles.forEach((bundle) =>
        {
            this.addBundle(bundle.name, bundle.assets);
        });
    }

    /**
     * This adds a bundle of assets in one go so that you can resolve them as a group.
     * For example you could add a bundle for each screen in you pixi app
     * @example
     *  resolver.addBundle('animals', {
     *    bunny: 'bunny.png',
     *    chicken: 'chicken.png',
     *    thumper: 'thumper.png',
     *  });
     *
     * const resolvedAssets = await resolver.resolveBundle('animals');
     * @param bundleId - The id of the bundle to add
     * @param assets - A record of the asset or assets that will be chosen from when loading via the specified key
     */
    public addBundle(bundleId: string, assets: ResolverBundle['assets']): void
    {
        const assetNames: string[] = [];

        if (Array.isArray(assets))
        {
            assets.forEach((asset) =>
            {
                if (typeof asset.name === 'string')
                {
                    assetNames.push(asset.name);
                }
                else
                {
                    assetNames.push(...asset.name);
                }

                this.add(asset.name, asset.srcs);
            });
        }
        else
        {
            Object.keys(assets).forEach((key) =>
            {
                assetNames.push(key);
                this.add(key, assets[key]);
            });
        }

        this._bundles[bundleId] = assetNames;
    }

    /**
     * Tells the resolver what keys are associated with witch asset.
     * The most important thing the resolver does
     * @example
     * // single key, single asset:
     * resolver.add('foo', 'bar.png');
     * resolver.resolveUrl('foo') // => 'bar.png'
     *
     * // multiple keys, single asset:
     * resolver.add(['foo', 'boo'], 'bar.png');
     * resolver.resolveUrl('foo') // => 'bar.png'
     * resolver.resolveUrl('boo') // => 'bar.png'
     *
     * // multiple keys, multiple assets:
     * resolver.add(['foo', 'boo'], ['bar.png', 'bar.webp']);
     * resolver.resolveUrl('foo') // => 'bar.png'
     *
     * // add custom data attached to the resolver
     * Resolver.add(
     *     'bunnyBooBooSmooth',
     *     'bunny{png,webp}',
     *     {scaleMode:SCALE_MODES.NEAREST} // base texture options
     * );
     *
     * resolver.resolve('bunnyBooBooSmooth') // => {src: 'bunny.png', data: {scaleMode: SCALE_MODES.NEAREST}}
     * @param keysIn - The keys to map, can be an array or a single key
     * @param assetsIn - The assets to associate with the key(s)
     * @param data - The data that will be attached to the object that resolved object.
     */
    public add(keysIn: string | string[], assetsIn: string | ResolveAsset | (ResolveAsset | string)[], data?: unknown): void
    {
        const keys: string[] = convertToList<string>(keysIn);

        keys.forEach((key) =>
        {
            if (this._assetMap[key])
            {
                // #if _DEBUG
                console.warn(`[Resolver] already has key: ${key} overwriting`);
                // #endif
            }
        });

        if (!Array.isArray(assetsIn))
        {
            if (typeof assetsIn === 'string')
            {
                assetsIn = createStringVariations(assetsIn);
            }
            else
            {
                assetsIn = [assetsIn];
            }
        }

        const assetMap: ResolveAsset[] = assetsIn.map((asset): ResolveAsset =>
        {
            let formattedAsset = asset as ResolveAsset;

            // check if is a string
            if (typeof asset === 'string')
            {
                // first see if it contains any {} tags...

                let parsed = false;

                for (let i = 0; i < this._parsers.length; i++)
                {
                    const parser = this._parsers[i];

                    if (parser.test(asset))
                    {
                        formattedAsset = parser.parse(asset);
                        parsed = true;
                        break;
                    }
                }

                if (!parsed)
                {
                    formattedAsset = {
                        src: asset,
                    };
                }
            }

            if (!formattedAsset.format)
            {
                formattedAsset.format = formattedAsset.src.split('.').pop();
            }

            if (!formattedAsset.alias)
            {
                formattedAsset.alias = keys;
            }

            if (this._basePath)
            {
                formattedAsset.src = makeAbsoluteUrl(formattedAsset.src, this._basePath);
            }

            formattedAsset.data = formattedAsset.data ?? data;

            return formattedAsset;
        });

        keys.forEach((key) =>
        {
            this._assetMap[key] = assetMap;
        });
    }

    /**
     * If the resolver has had a manifest set via setManifest, this will return the assets urls for
     * a given bundleId or bundleIds.
     * @example
     * // manifest example
     * const manifest = {
     *   bundles:[{
     *      name:'load-screen',
     *      assets:[
     *          {
     *             name: 'background',
     *             srcs: 'sunset.png',
     *          },
     *          {
     *             name: 'bar',
     *             srcs: 'load-bar.{png,webp}',
     *          }
     *      ]
     *   },
     *   {
     *      name:'game-screen',
     *      assets:[
     *          {
     *             name: 'character',
     *             srcs: 'robot.png',
     *          },
     *          {
     *             name: 'enemy',
     *             srcs: 'bad-guy.png',
     *          }
     *      ]
     *   }]
     * }}
     * resolver.setManifest(manifest);
     * const resolved = resolver.resolveBundle('load-screen');
     * @param bundleIds - The bundle ids to resolve
     * @returns All the bundles assets or a hash of assets for each bundle specified
     */
    public resolveBundle(bundleIds: string | string[]):
    Record<string, ResolveAsset> | Record<string, Record<string, ResolveAsset>>
    {
        const singleAsset = isSingleItem(bundleIds);

        bundleIds = convertToList<string>(bundleIds);

        const out: Record<string, Record<string, ResolveAsset>> = {};

        bundleIds.forEach((bundleId) =>
        {
            const assetNames = this._bundles[bundleId];

            if (assetNames)
            {
                out[bundleId] = this.resolve(assetNames) as Record<string, ResolveAsset>;
            }
        });

        return singleAsset ? out[bundleIds[0]] : out;
    }

    /**
     * Does exactly what resolve does, but returns just the URL rather than the whole asset object
     * @param key - The key or keys to resolve
     * @returns - The URLs associated with the key(s)
     */
    public resolveUrl(key: string | string[]): string | Record<string, string>
    {
        const result = this.resolve(key);

        if (typeof key !== 'string')
        {
            const out: Record<string, string> = {};

            for (const i in result)
            {
                out[i] = (result as Record<string, ResolveAsset>)[i].src;
            }

            return out;
        }

        return (result as ResolveAsset).src;
    }

    /**
     * Resolves each key in the list to an asset object.
     * Another key function of the resolver! After adding all the various key/asset pairs. this will run the logic
     * of finding which asset to return based on any preferences set using the `prefer` function
     * by default the same key passed in will be returned if nothing is matched by the resolver.
     * @example
     * resolver.add('boo', 'bunny.png');
     *
     * resolver.resolve('boo') // => {src:'bunny.png'}
     *
     * // will return the same string as no key was added for this value..
     * resolver.resolve('another-thing.png') // => {src:'another-thing.png'}
     * @param keys - key or keys to resolve
     * @returns - the resolve asset or a hash of resolve assets for each key specified
     */
    public resolve(keys: string | string[]): ResolveAsset | Record<string, ResolveAsset>
    {
        const singleAsset = isSingleItem(keys);

        keys = convertToList<string>(keys);

        const result: Record<string, ResolveAsset> = {};

        keys.forEach((key) =>
        {
            if (!this._resolverHash[key])
            {
                if (this._assetMap[key])
                {
                    let assets = this._assetMap[key];

                    const preferredOrder = this._getPreferredOrder(assets);

                    const bestAsset = assets[0];

                    preferredOrder?.priority.forEach((priorityKey) =>
                    {
                        preferredOrder.params[priorityKey].forEach((value: unknown) =>
                        {
                            const filteredAssets = assets.filter((asset) =>
                            {
                                if (asset[priorityKey])
                                {
                                    return asset[priorityKey] === value;
                                }

                                return false;
                            });

                            if (filteredAssets.length)
                            {
                                assets = filteredAssets;
                            }
                        });
                    });

                    this._resolverHash[key] = (assets[0] ?? bestAsset);
                }
                else
                {
                    let src = key;

                    if (this._basePath)
                    {
                        src = makeAbsoluteUrl(src, this._basePath);
                    }

                    // if the resolver fails we just pass back the key assuming its a url
                    this._resolverHash[key] = {
                        src,
                    };
                }
            }

            result[key] = this._resolverHash[key];
        });

        return singleAsset ? result[keys[0]] : result;
    }

    /**
     * Internal function for figuring out what prefer criteria an asset should use.
     * @param assets
     */
    private _getPreferredOrder(assets: ResolveAsset[]): PreferOrder
    {
        for (let i = 0; i < assets.length; i++)
        {
            const asset = assets[0];

            const preferred =  this._preferredOrder.find((preference: PreferOrder) =>
                preference.params.format.includes(asset.format));

            if (preferred)
            {
                return preferred;
            }
        }

        return this._preferredOrder[0];
    }
}