From dad948469f175fd64317d46be5f2124dbe208f20 Mon Sep 17 00:00:00 2001 From: HoshinoSuzumi Date: Mon, 18 Nov 2024 03:56:02 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=9A=20Runtime=20libs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- playground/nuxt.config.ts | 7 +- src/module.ts | 28 ++- src/runtime/components/elements/Button.vue | 68 +++++++ src/runtime/components/forms/Input.vue | 11 ++ src/runtime/components/icons/CircleError.vue | 10 ++ src/runtime/components/icons/CircleInfo.vue | 7 + .../components/icons/CircleSuccess.vue | 10 ++ .../components/icons/CircleWarning.vue | 10 ++ src/runtime/components/icons/Spinner.vue | 11 ++ src/runtime/components/overlays/Message.vue | 62 +++++++ .../components/overlays/MessageProvider.vue | 95 ++++++++++ src/runtime/composables/useMessage.ts | 9 + src/runtime/composables/useUI.ts | 29 +++ src/runtime/plugins/colors.ts | 87 +++++++++ src/runtime/types/button.d.ts | 23 +++ src/runtime/types/message.d.ts | 20 +++ src/runtime/types/utils.d.ts | 50 ++++++ src/runtime/ui.config/elements/button.ts | 47 +++++ src/runtime/ui.config/index.ts | 2 + src/runtime/utils/colors.ts | 167 ++++++++++++++++++ src/runtime/utils/index.ts | 28 +++ src/runtime/utils/objectUtils.ts | 37 ++++ 22 files changed, 809 insertions(+), 9 deletions(-) create mode 100644 src/runtime/components/elements/Button.vue create mode 100644 src/runtime/components/forms/Input.vue create mode 100644 src/runtime/components/icons/CircleError.vue create mode 100644 src/runtime/components/icons/CircleInfo.vue create mode 100644 src/runtime/components/icons/CircleSuccess.vue create mode 100644 src/runtime/components/icons/CircleWarning.vue create mode 100644 src/runtime/components/icons/Spinner.vue create mode 100644 src/runtime/components/overlays/Message.vue create mode 100644 src/runtime/components/overlays/MessageProvider.vue create mode 100644 src/runtime/composables/useMessage.ts create mode 100644 src/runtime/composables/useUI.ts create mode 100644 src/runtime/plugins/colors.ts create mode 100644 src/runtime/types/button.d.ts create mode 100644 src/runtime/types/message.d.ts create mode 100644 src/runtime/types/utils.d.ts create mode 100644 src/runtime/ui.config/elements/button.ts create mode 100644 src/runtime/ui.config/index.ts create mode 100644 src/runtime/utils/colors.ts create mode 100644 src/runtime/utils/index.ts create mode 100644 src/runtime/utils/objectUtils.ts diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 4c483e3..d7d36f0 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -1,5 +1,6 @@ export default defineNuxtConfig({ - modules: ['../src/module'], - myModule: {}, + compatibilityDate: "2024-11-18", devtools: { enabled: true }, -}) + modules: ["../src/module"], + rayui: {}, +}); diff --git a/src/module.ts b/src/module.ts index 7d9c192..3bc45d3 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,19 +1,35 @@ -import { defineNuxtModule, addPlugin, createResolver } from '@nuxt/kit' +import { defineNuxtModule, addPlugin, createResolver } from "@nuxt/kit"; +import type { config } from "process"; +import type { Strategy, DeepPartial } from "./runtime/types/utils"; + +export type RayUI = { + primary?: string; + gray?: string; + strategy?: Strategy; + colors?: string[]; + [key: string]: any; +} & DeepPartial; + +declare module "@nuxt/schema" { + interface AppConfigInput { + rayui?: RayUI; + } +} // Module options TypeScript interface definition export interface ModuleOptions {} export default defineNuxtModule({ meta: { - name: 'my-module', - configKey: 'myModule', + name: "rayine/ui", + configKey: "rayui", }, // Default configuration options of the Nuxt module defaults: {}, setup(_options, _nuxt) { - const resolver = createResolver(import.meta.url) + const resolver = createResolver(import.meta.url); // Do not add the extension since the `.ts` will be transpiled to `.mjs` after `npm run prepack` - addPlugin(resolver.resolve('./runtime/plugin')) + addPlugin(resolver.resolve("./runtime/plugin")); }, -}) +}); diff --git a/src/runtime/components/elements/Button.vue b/src/runtime/components/elements/Button.vue new file mode 100644 index 0000000..435eea7 --- /dev/null +++ b/src/runtime/components/elements/Button.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/runtime/components/forms/Input.vue b/src/runtime/components/forms/Input.vue new file mode 100644 index 0000000..f0343ac --- /dev/null +++ b/src/runtime/components/forms/Input.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/src/runtime/components/icons/CircleError.vue b/src/runtime/components/icons/CircleError.vue new file mode 100644 index 0000000..4d84356 --- /dev/null +++ b/src/runtime/components/icons/CircleError.vue @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/src/runtime/components/icons/CircleInfo.vue b/src/runtime/components/icons/CircleInfo.vue new file mode 100644 index 0000000..660e8e1 --- /dev/null +++ b/src/runtime/components/icons/CircleInfo.vue @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/src/runtime/components/icons/CircleSuccess.vue b/src/runtime/components/icons/CircleSuccess.vue new file mode 100644 index 0000000..8a64ee6 --- /dev/null +++ b/src/runtime/components/icons/CircleSuccess.vue @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/src/runtime/components/icons/CircleWarning.vue b/src/runtime/components/icons/CircleWarning.vue new file mode 100644 index 0000000..5a5aebc --- /dev/null +++ b/src/runtime/components/icons/CircleWarning.vue @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/src/runtime/components/icons/Spinner.vue b/src/runtime/components/icons/Spinner.vue new file mode 100644 index 0000000..cab4a3f --- /dev/null +++ b/src/runtime/components/icons/Spinner.vue @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/src/runtime/components/overlays/Message.vue b/src/runtime/components/overlays/Message.vue new file mode 100644 index 0000000..da582f3 --- /dev/null +++ b/src/runtime/components/overlays/Message.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/src/runtime/components/overlays/MessageProvider.vue b/src/runtime/components/overlays/MessageProvider.vue new file mode 100644 index 0000000..d25fc04 --- /dev/null +++ b/src/runtime/components/overlays/MessageProvider.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/src/runtime/composables/useMessage.ts b/src/runtime/composables/useMessage.ts new file mode 100644 index 0000000..263d45d --- /dev/null +++ b/src/runtime/composables/useMessage.ts @@ -0,0 +1,9 @@ +import type { MessageApi } from "../types/message"; + +export const useMessage = () => { + const message = inject("ray-message"); + if (!message) { + throw new Error("No outer message-provider found!"); + } + return message; +}; diff --git a/src/runtime/composables/useUI.ts b/src/runtime/composables/useUI.ts new file mode 100644 index 0000000..ba8fe9e --- /dev/null +++ b/src/runtime/composables/useUI.ts @@ -0,0 +1,29 @@ +import type { DeepPartial, Strategy } from "../types/utils"; + +export const useUI = ( + key: string, + ui?: Ref<(DeepPartial & { strategy?: Strategy }) | undefined>, + config?: T | Ref +) => { + const _attrs = useAttrs(); + const appConfig = useAppConfig(); + + const attrs = computed(() => omit(_attrs, ["class"])); + + const _computedUiConfig = computed(() => { + const _ui = toValue(ui); + const _config = toValue(config); + + return mergeUiConfig( + _ui?.strategy || (appConfig.rayui?.strategy as Strategy), + _ui || {}, + getValueByPath(appConfig.rayui, key, {}), + _config || {} + ); + }); + + return { + ui: _computedUiConfig, + attrs, + }; +}; diff --git a/src/runtime/plugins/colors.ts b/src/runtime/plugins/colors.ts new file mode 100644 index 0000000..e992ce4 --- /dev/null +++ b/src/runtime/plugins/colors.ts @@ -0,0 +1,87 @@ +import { computed } from "vue"; +import { defineNuxtPlugin, useAppConfig, useNuxtApp, useHead } from "#imports"; +import colors from "tailwindcss/colors"; + +const rgbHexPattern = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i; + +function hexToRgb(hex: string) { + const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + hex = hex.replace(shorthandRegex, function (_, r, g, b) { + return r + r + g + g + b + b; + }); + + const result = rgbHexPattern.exec(hex); + return result + ? `${Number.parseInt(result[1], 16)} ${Number.parseInt( + result[2], + 16 + )} ${Number.parseInt(result[3], 16)}` + : null; +} + +function parseConfigValue(value: string) { + return rgbHexPattern.test(value) ? hexToRgb(value) : value; +} + +export default defineNuxtPlugin(() => { + const appConfig = useAppConfig(); + const nuxtApp = useNuxtApp(); + + const root = computed(() => { + const primary: Record | undefined = getValueByPath( + colors, + appConfig.rayui.primary + ); + const gray: Record | undefined = getValueByPath( + colors, + appConfig.rayui.gray + ); + + return `:root { +${Object.entries(primary || colors.indigo) + .map(([key, value]) => `--color-primary-${key}: ${parseConfigValue(value)};`) + .join("\n")} +--color-primary-DEFAULT: var(--color-primary-500); + +${Object.entries(gray || colors.neutral) + .map(([key, value]) => `--color-gray-${key}: ${parseConfigValue(value)};`) + .join("\n")} +} + +.dark { + --color-primary-DEFAULT: var(--color-primary-400); +} +`; + }); + + const headData: any = { + style: [ + { + innerHTML: () => root.value, + tagPriority: -2, + id: "ray-colors", + }, + ], + }; + + if ( + import.meta.client && + nuxtApp.isHydrating && + !nuxtApp.payload.serverRendered + ) { + const style = document.createElement("style"); + + style.innerHTML = root.value; + style.setAttribute("data-ray-colors", ""); + document.head.appendChild(style); + + headData.script = [ + { + innerHTML: + "document.head.removeChild(document.querySelector('[data-ray-colors]'))", + }, + ]; + } + + useHead(headData); +}); diff --git a/src/runtime/types/button.d.ts b/src/runtime/types/button.d.ts new file mode 100644 index 0000000..489514f --- /dev/null +++ b/src/runtime/types/button.d.ts @@ -0,0 +1,23 @@ +import type { button } from "../ui.config"; +import type colors from "#ray-colors"; +import type { ExtractDeepObject, NestedKeyOf, ExtractDeepKey } from "./utils"; +import type { AppConfig } from "nuxt/schema"; + +export type ButtonSize = + | keyof typeof button.size + | ExtractDeepKey; +export type ButtonColor = + | keyof typeof button.color + | ExtractDeepKey + | (typeof colors)[number]; +export type ButtonVariant = + | keyof typeof button.variant + | ExtractDeepKey + | NestedKeyOf + | NestedKeyOf>; + +export interface Button { + size?: ButtonSize; + color?: ButtonColor; + variant?: ButtonVariant; +} diff --git a/src/runtime/types/message.d.ts b/src/runtime/types/message.d.ts new file mode 100644 index 0000000..1b0e13f --- /dev/null +++ b/src/runtime/types/message.d.ts @@ -0,0 +1,20 @@ +export type MessageType = "success" | "warning" | "error" | "info"; + +export interface Message { + id: string; + content: string; + type: MessageType; + duration?: number; +} + +export interface MessageApi { + info: (content: string, duration?: number) => void; + success: (content: string, duration?: number) => void; + warning: (content: string, duration?: number) => void; + error: (content: string, duration?: number) => void; + destroyAll: () => void; +} + +export interface MessageProviderApi { + destroy: (id: string) => void; +} diff --git a/src/runtime/types/utils.d.ts b/src/runtime/types/utils.d.ts new file mode 100644 index 0000000..f4c5fa2 --- /dev/null +++ b/src/runtime/types/utils.d.ts @@ -0,0 +1,50 @@ +export type Strategy = "override" | "merge"; + +export interface TightMap { + [key: string]: TightMap | O; +} + +export type DeepPartial = { + [P in keyof T]?: T[P] extends object + ? DeepPartial + : T[P] extends string + ? string + : T[P]; +} & { + [key: string]: O | TightMap; +}; + +export type NestedKeyOf> = { + [Key in keyof ObjectType]: ObjectType[Key] extends Record + ? NestedKeyOf + : Key; +}[keyof ObjectType]; + +type DeepKey = Keys extends [ + infer First, + ...infer Rest +] + ? First extends keyof T + ? Rest extends string[] + ? DeepKey + : never + : never + : T; + +export type ExtractDeepKey = DeepKey< + T, + Path +> extends infer Result + ? Result extends Record + ? keyof Result + : never + : never; + +export type ExtractDeepObject = DeepKey< + T, + Path +> extends infer Result + ? Result extends Record + ? Result + : never + : never; diff --git a/src/runtime/ui.config/elements/button.ts b/src/runtime/ui.config/elements/button.ts new file mode 100644 index 0000000..b426438 --- /dev/null +++ b/src/runtime/ui.config/elements/button.ts @@ -0,0 +1,47 @@ +export default { + base: "focus:outline-none focus-visible:outline-0 disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:cursor-not-allowed aria-disabled:opacity-75 flex-shrink-0 transition", + rounded: "rounded-lg", + font: "font-medium", + block: "w-full flex justify-center items-center", + inline: "inline-flex items-center", + size: { + "2xs": "text-xs", + xs: "text-xs", + sm: "text-sm", + md: "text-sm", + lg: "text-sm", + xl: "text-base", + }, + padding: { + "2xs": "px-2 py-1", + xs: "px-2.5 py-1.5", + sm: "px-2.5 py-1.5", + md: "px-3 py-2", + lg: "px-3.5 py-2.5", + xl: "px-3.5 py-2.5", + }, + square: { + "2xs": "p-1", + xs: "p-1.5", + sm: "p-1.5", + md: "p-2", + lg: "p-2.5", + xl: "p-2.5", + }, + color: {}, + variant: { + solid: + "shadow-sm hover:shadow-md disabled:hover:shadow-sm active:shadow-none bg-{color}-500 disabled:bg-{color}-500 aria-disabled:bg-{color}-500 hover:bg-{color}-600 text-white active:bg-{color}-700 dark:active:bg-{color}-500 focus:ring focus:ring-{color}-300 focus:ring-opacity-50 dark:focus:ring-opacity-20", + outline: + "ring-1 ring-inset ring-current ring-{color}-500 text-{color}-500 dark:hover:text-{color}-400 dark:hover:text-{color}-500 hover:bg-{color}-100 dark:hover:bg-{color}-900 disabled:bg-transparent disabled:hover:bg-transparent aria-disabled:bg-transparent focus-visible:ring-2 focus-visible:ring-{color}-500 dark:focus-visible:ring-{color}-400", + soft: "text-{color}-500 dark:text-{color}-400 bg-{color}-50 hover:bg-{color}-100 disabled:bg-{color}-50 aria-disabled:bg-{color}-50 dark:bg-{color}-950 dark:hover:bg-{color}-900 dark:disabled:bg-{color}-950 dark:aria-disabled:bg-{color}-950 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-{color}-500 dark:focus-visible:ring-{color}-400 transition-none", + ghost: + "text-{color}-500 dark:text-{color}-400 hover:bg-{color}-50 disabled:bg-transparent aria-disabled:bg-transparent dark:hover:bg-{color}-950 dark:disabled:bg-transparent dark:aria-disabled:bg-transparent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-{color}-500 dark:focus-visible:ring-{color}-400", + link: "text-{color}-500 hover:text-{color}-600 disabled:text-{color}-500 aria-disabled:text-{color}-500 dark:text-{color}-400 dark:hover:text-{color}-500 dark:disabled:text-{color}-400 dark:aria-disabled:text-{color}-400 underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-{color}-500 dark:focus-visible:ring-{color}-400", + }, + default: { + size: "sm", + color: "primary", + variant: "solid", + }, +}; diff --git a/src/runtime/ui.config/index.ts b/src/runtime/ui.config/index.ts new file mode 100644 index 0000000..e329355 --- /dev/null +++ b/src/runtime/ui.config/index.ts @@ -0,0 +1,2 @@ +// elements +export { default as button } from './elements/button' diff --git a/src/runtime/utils/colors.ts b/src/runtime/utils/colors.ts new file mode 100644 index 0000000..a637b30 --- /dev/null +++ b/src/runtime/utils/colors.ts @@ -0,0 +1,167 @@ +import type { Config as TwConfig } from "tailwindcss"; +import defaultColors from "tailwindcss/colors"; +import type { SafelistConfig } from "tailwindcss/types/config"; + +// @ts-ignore +delete defaultColors.lightBlue; +// @ts-ignore +delete defaultColors.warmGray; +// @ts-ignore +delete defaultColors.trueGray; +// @ts-ignore +delete defaultColors.coolGray; +// @ts-ignore +delete defaultColors.blueGray; + +const colorsToRegex = (colors: string[]): string => colors.join("|"); + +type ColorConfig = Exclude["colors"], Function>; + +export const setColors = (theme: TwConfig["theme"]) => { + const globalColors: ColorConfig = { + ...(theme?.colors || defaultColors), + ...theme?.extend?.colors, + }; + + // @ts-ignore + globalColors.primary = theme.extend.colors.primary = { + 50: "rgb(var(--color-primary-50) / )", + 100: "rgb(var(--color-primary-100) / )", + 200: "rgb(var(--color-primary-200) / )", + 300: "rgb(var(--color-primary-300) / )", + 400: "rgb(var(--color-primary-400) / )", + 500: "rgb(var(--color-primary-500) / )", + 600: "rgb(var(--color-primary-600) / )", + 700: "rgb(var(--color-primary-700) / )", + 800: "rgb(var(--color-primary-800) / )", + 900: "rgb(var(--color-primary-900) / )", + 950: "rgb(var(--color-primary-950) / )", + DEFAULT: "rgb(var(--color-primary-DEFAULT) / )", + }; + + if (globalColors.gray) { + // @ts-ignore + globalColors.cool = theme.extend.colors.cool = defaultColors.gray; + } + + // @ts-ignore + globalColors.gray = theme.extend.colors.gray = { + 50: "rgb(var(--color-gray-50) / )", + 100: "rgb(var(--color-gray-100) / )", + 200: "rgb(var(--color-gray-200) / )", + 300: "rgb(var(--color-gray-300) / )", + 400: "rgb(var(--color-gray-400) / )", + 500: "rgb(var(--color-gray-500) / )", + 600: "rgb(var(--color-gray-600) / )", + 700: "rgb(var(--color-gray-700) / )", + 800: "rgb(var(--color-gray-800) / )", + 900: "rgb(var(--color-gray-900) / )", + 950: "rgb(var(--color-gray-950) / )", + }; + + return Object.entries(globalColors) + .filter(([, value]) => typeof value === "object") + .map(([key]) => key); +}; + +const safelistForComponent: Record< + string, + (colors: string) => TwConfig["safelist"] +> = { + button: (colorsToRegex) => [ + { + pattern: RegExp(`^bg-(${colorsToRegex})-50$`), + variants: ["hover", "disabled"], + }, + { + pattern: RegExp(`^bg-(${colorsToRegex})-100$`), + variants: ["hover"], + }, + { + pattern: RegExp(`^bg-(${colorsToRegex})-400$`), + variants: ["dark", "dark:disabled"], + }, + { + pattern: RegExp(`^bg-(${colorsToRegex})-500$`), + variants: ["disabled", "dark:hover", "dark:active"], + }, + { + pattern: RegExp(`^bg-(${colorsToRegex})-600$`), + variants: ["hover"], + }, + { + pattern: RegExp(`^bg-(${colorsToRegex})-700$`), + variants: ["active"], + }, + { + pattern: RegExp(`^bg-(${colorsToRegex})-900$`), + variants: ["dark:hover"], + }, + { + pattern: RegExp(`^bg-(${colorsToRegex})-950$`), + variants: ["dark", "dark:hover", "dark:disabled"], + }, + { + pattern: RegExp(`^text-(${colorsToRegex})-400$`), + variants: ["dark", "dark:hover", "dark:disabled"], + }, + { + pattern: RegExp(`^text-(${colorsToRegex})-500$`), + variants: ["dark:hover", "disabled"], + }, + { + pattern: RegExp(`^text-(${colorsToRegex})-600$`), + variants: ["hover"], + }, + { + pattern: RegExp(`^outline-(${colorsToRegex})-400$`), + variants: ["dark:focus-visible"], + }, + { + pattern: RegExp(`^outline-(${colorsToRegex})-500$`), + variants: ["focus-visible"], + }, + { + pattern: RegExp(`^ring-(${colorsToRegex})-300$`), + variants: ["focus", "dark:focus"], + }, + { + pattern: RegExp(`^ring-(${colorsToRegex})-400$`), + variants: ["dark:focus-visible"], + }, + { + pattern: RegExp(`^ring-(${colorsToRegex})-500$`), + variants: ["focus-visible"], + }, + ], +}; + +export const generateSafelist = ( + colors: string[], + globalColors: string[] +): string[] => { + const safelist = Object.keys(safelistForComponent) + .flatMap((component) => + safelistForComponent[component](colorsToRegex(colors)) + ) + .filter( + (item): item is Exclude => item !== undefined + ); + + const extractColorsFromPattern = (pattern: RegExp): string[] => { + const matches = pattern.source.match(/\(([^)]+)\)/); + if (!matches) return []; + return matches[1].split("|").map((color) => + pattern.source.replace(matches[0], color).replace(/[\^\$]/g, "") + ); + }; + + return safelist.flatMap((item) => { + const replacedStrings = extractColorsFromPattern(item.pattern); + return replacedStrings.concat( + item.variants?.flatMap((variant) => + replacedStrings.map((str) => `${variant}:${str}`) + ) || [] + ); + }); +}; diff --git a/src/runtime/utils/index.ts b/src/runtime/utils/index.ts new file mode 100644 index 0000000..a6978f9 --- /dev/null +++ b/src/runtime/utils/index.ts @@ -0,0 +1,28 @@ +import { defu, createDefu } from "defu"; +import { extendTailwindMerge } from "tailwind-merge"; +import type { Strategy } from "../types/utils"; + +const custonTwMerge = extendTailwindMerge({}); + +export const twMergeDefu = createDefu((obj, key, val, namespace) => { + if (namespace === "default" || namespace.startsWith("default.")) { + return false; + } + if ( + typeof obj[key] === "string" && + typeof val === "string" && + obj[key] && + val + ) { + // @ts-ignore + obj[key] = custonTwMerge(obj[key], val); + return true; + } +}); + +export const mergeUiConfig = (strategy: Strategy, ...configs: any): T => { + if (strategy === "merge") { + return twMergeDefu({}, ...configs) as T; + } + return defu({}, ...configs) as T; +}; diff --git a/src/runtime/utils/objectUtils.ts b/src/runtime/utils/objectUtils.ts new file mode 100644 index 0000000..570ecce --- /dev/null +++ b/src/runtime/utils/objectUtils.ts @@ -0,0 +1,37 @@ +export const omit = , K extends keyof T>( + object: T, + keysToOmit: K[] | any[] +): Pick> => { + const result = { ...object }; + + for (const key of keysToOmit) { + delete result[key]; + } + + return result; +}; + +export const getValueByPath = ( + obj: Record, + path: string | (string | number)[], + defaultValue?: any +): any => { + if (typeof path === "string") { + path = path.split(".").map((key) => { + const num = Number(key); + return Number.isNaN(num) ? key : num; + }); + } + + let result = obj; + + for (const key of path) { + if (result === undefined || result === null) { + return defaultValue; + } + + result = result[key]; + } + + return result !== undefined ? result : defaultValue; +};