UNPKG

jfather

Version:

JSON with merge, extend and override.

233 lines (213 loc) 7.06 kB
/** * @module * @license MIT * @author Sébastien Règne */ /** * Les options des fonctions de JFather. * * @typedef {Object} Options * @prop {Function} [request] La fonction pour récupérer un objet JSON à * distance. Par défaut, l'objet est récupéré avec * `fetch()` et `Response.json()`. */ /** * Exécute une fonction sur un objet et tous ses sous-objets (en partant des * objets les plus profonds). * * @param {any} obj Une variable quelconque. * @param {Function} fn La fonction appliquée sur tous les objets. * @returns {any} Le retour de la fonction. */ export const walk = function (obj, fn) { if (Object === obj?.constructor) { return fn( Object.fromEntries( Object.entries(obj).map(([k, v]) => [k, walk(v, fn)]), ), ); } if (Array.isArray(obj)) { return obj.map((v) => walk(v, fn)); } return obj; }; /** * Exécute une fonction asynchrone sur un objet et tous ses sous-objets (en * partant des objets les plus profonds). * * @param {any} obj Une variable quelconque. * @param {Function} fn La fonction asynchrone appliquée sur tous les objets. * @returns {Promise<any>} Une promesse contenant le retour de la fonction. */ export const walkAsync = async function (obj, fn) { if (Object === obj?.constructor) { return await fn( Object.fromEntries( await Promise.all( Object.entries(obj).map(async ([key, value]) => [ key, await walkAsync(value, fn), ]), ), ), ); } if (Array.isArray(obj)) { return await Promise.all(obj.map((v) => walkAsync(v, fn))); } return obj; }; /** * Clone récursivement un objet. * * @param {any} obj Une variable quelconque. * @returns {any} Le clone de la variable d'entrée. */ export const clone = function (obj) { return walk(obj, (/** @type {any} */ v) => v); }; /** * Extrait un élément d'un objet. * * @param {Record<string, any>} obj L'objet où sera extrait l'élément. * @param {string} chain Le chemin de l'élément. * @returns {any} L'élément extrait. * @throws {TypeError} Si le chemin est invalide. */ export const query = function (obj, chain) { if ("" === chain) { return obj; } const re = /^\.(?<prop>\w+)|^\[(?<index>\d+)\]/v; const sub = { obj, // Préfixer le chemin avec un point si nécessaire. chain: /^[.\[]/v.test(chain) ? chain : "." + chain, }; while (0 !== sub.chain.length) { const result = re.exec(sub.chain); if (undefined !== result?.groups?.prop) { sub.obj = sub.obj[result.groups.prop]; // eslint-disable-next-line no-negated-condition } else if (undefined !== result?.groups?.index) { sub.obj = sub.obj[Number(result.groups.index)]; } else { throw new TypeError(`Invalid chain: ${chain}`); } sub.chain = sub.chain.slice(result[0].length); } return sub.obj; }; /** * Fusionne deux objets récursivement. * * @param {any} parent L'objet parent. * @param {any} child L'objet enfant. * @returns {any} La fusion des deux objets. */ export const merge = function (parent, child) { if ( child === parent || Object !== parent?.constructor || Object !== child?.constructor ) { return clone(child); } const overridden = /** @type {Record<string, any>} */ ({}); for (const key of new Set([ ...Object.keys(parent), ...Object.keys(child), ])) { // Ne pas copier les surcharges d'éléments. if (key.startsWith("$")) { continue; } // Si la propriété est dans les deux objets : fusionner les deux // valeurs. if (key in parent && key in child) { overridden[key] = merge(parent[key], child[key]); // Si la propriété est seulement dans l'objet parent. } else if (key in parent) { overridden[key] = clone(parent[key]); // Si la propriété est seulement dans l'objet enfant. } else { overridden[key] = clone(child[key]); } // Si la valeur est un tableau : chercher si l'objet enfant a des // surcharges d'éléments. if (Array.isArray(overridden[key])) { const overelemRegex = new RegExp( `^\\$${key}\\[(?<index>\\d*)\\]$`, "v", ); const overelems = Object.entries(child) .map(([k, v]) => [overelemRegex.exec(k)?.groups?.index, v]) .filter(([i]) => undefined !== i); for (const [index, value] of overelems) { if ("" === index) { overridden[key].push(...clone(value)); } else { overridden[key][Number(index)] = merge( overridden[key][Number(index)], value, ); } } } } return overridden; }; /** * Étendre un objet JSON en utilisant la propriété `"$extends"`. * * @param {Record<string, any>} obj L'objet qui sera étendu. * @param {Options} [options] Les options. * @returns {Promise<Record<string, any>>} Une promesse contenant l'objet * étendu. */ export const inherit = async function (obj, options) { if (undefined === obj.$extends) { return obj; } // eslint-disable-next-line no-use-before-define return merge(await load(obj.$extends, options), obj); }; /** * Étendre un objet récursivement. * * @param {any} obj L'objet qui sera étendu. * @param {Options} [options] Les options. * @returns {Promise<any>} Une promesse contenant l'objet étendu. */ export const extend = function (obj, options) { return walkAsync(obj, (/** @type {any} */ v) => inherit(v, options)); }; /** * Charge un objet JSON depuis une URL. * * @param {string|URL} url L'URL du fichier JSON. * @param {Options} [options] Les options. * @returns {Promise<any>} Une promesse contenant l'objet. */ export const load = async function (url, options) { let json; if (undefined === options?.request) { const response = await fetch(url); json = await response.json(); } else { json = await options.request(url); } // Enlever le "#" dans le hash de l'URL. return await extend(query(json, new URL(url).hash.slice(1)), options); }; /** * Parse une chaine de caractères. * * @param {string} text La chaine de caractères qui sera parsée. * @param {Options} [options] Les options. * @returns {Promise<any>} L'objet. */ export const parse = function (text, options) { return extend(JSON.parse(text), options); };