diff --git a/.changeset/nice-books-mate.md b/.changeset/nice-books-mate.md new file mode 100644 index 000000000000..7967adfbfa7c --- /dev/null +++ b/.changeset/nice-books-mate.md @@ -0,0 +1,6 @@ +--- +"@withstudiocms/config-utils": minor +"studiocms": minor +--- + +Migrates to a Vite-based loader with native ESM and SSR fallback support; callers now use the filesystem-aware options API. diff --git a/packages/@withstudiocms/config-utils/package.json b/packages/@withstudiocms/config-utils/package.json index f19027644d7c..2631f2f9ed87 100644 --- a/packages/@withstudiocms/config-utils/package.json +++ b/packages/@withstudiocms/config-utils/package.json @@ -53,15 +53,14 @@ } }, "type": "module", - "dependencies": { - "tsdown": "catalog:" - }, "devDependencies": { "@types/node": "catalog:", - "astro": "catalog:astrojs-current" + "astro": "catalog:astrojs-current", + "vite": "catalog:astrojs-current" }, "peerDependencies": { "astro": "catalog:astrojs-min", + "vite": "catalog:astrojs-min", "effect": "catalog:effect" } } diff --git a/packages/@withstudiocms/config-utils/src/astro-integration-utils.ts b/packages/@withstudiocms/config-utils/src/astro-integration-utils.ts index 9a0d10bca438..27ecefd5113e 100644 --- a/packages/@withstudiocms/config-utils/src/astro-integration-utils.ts +++ b/packages/@withstudiocms/config-utils/src/astro-integration-utils.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import type { HookParameters } from 'astro'; import { Schema } from 'effect'; import { loadConfigFile } from './loader.js'; @@ -27,16 +28,16 @@ export const configResolverBuilder = const logger = l.fork(`${label}:config`); // Load the config file - const loadedConfigFile = await loadConfigFile(astroRoot, configPaths, label); + const loadedViteConfigFile = await loadConfigFile({ configPaths, root: astroRoot, fs }); - // If no config file was found, return the result of the effect with an empty object as input - if (!loadedConfigFile) { - logger.info('No config file found. Using default configuration.'); + // If no config file is found, log a message and return the default configuration using the effect schema + if (Object.keys(loadedViteConfigFile).length === 0) { + logger.info('No config entries found. Using default configuration.'); return await Schema.decodeUnknownPromise(effectSchema)({}); } // Parse the loaded config file using the provided effect schema - const parsedConfig = await Schema.decodeUnknownPromise(effectSchema)(loadedConfigFile); + const parsedConfig = await Schema.decodeUnknownPromise(effectSchema)(loadedViteConfigFile); // Log a message indicating that the config file was loaded and parsed successfully logger.info('Config file loaded and parsed successfully.'); diff --git a/packages/@withstudiocms/config-utils/src/loader.ts b/packages/@withstudiocms/config-utils/src/loader.ts index 2b3525935696..c7f965edf62c 100644 --- a/packages/@withstudiocms/config-utils/src/loader.ts +++ b/packages/@withstudiocms/config-utils/src/loader.ts @@ -1,121 +1,35 @@ -import { constants } from 'node:fs'; -import { access, unlink, writeFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; -import { build as tsdown } from 'tsdown'; -import type { - ImportBundledFileArgs, - LoadAndBundleConfigFileArgs, - LoadAndBundleConfigFileResult, -} from './types.js'; -import { tryCatch } from './utils/tryCatch.js'; +import type fsType from 'node:fs'; +import { isRunnableDevEnvironment, type ViteDevServer } from 'vite'; +import { createMinimalViteDevServer } from './utils/createMinimalViteDevServer.js'; +import loadFallbackPlugin from './utils/vite-plugin-load-fallback.js'; /** - * Bundle arbitrary `mjs` or `ts` file. - * Simplified fork from Vite's `bundleConfigFile` function. + * Options for loading a configuration file with Vite. * - * @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L961 + * @property root - The root directory for resolving imports. + * @property configPaths - An array of possible configuration file paths to check, relative to `root`. + * @property fs - The file system module to use for checking file existence. */ -export async function bundleConfigFile({ fileUrl }: { fileUrl: URL }) { - const result = await tsdown({ - cwd: process.cwd(), - entry: fileURLToPath(fileUrl), - format: 'esm', - target: 'node18', - platform: 'node', - logLevel: 'silent', - write: false, - }); - - const chunks = result[0].chunks.filter((chunk) => chunk.type === 'chunk'); - - if (chunks.length === 0) { - throw new Error('No output chunks found after bundling config file'); - } - - const file = chunks.find((chunk) => chunk.fileName.endsWith('.mjs'))?.code; - - if (!file) { - throw new Error('Unexpected: no output file'); - } - - return { - code: file, - }; +export interface LoadConfigWithViteOptions { + root: URL; + configPaths: string[]; + fs: typeof fsType; } /** - * Forked from Vite config loader, replacing CJS-based path concat with ESM only + * Loads a configuration file using Vite's development server. * - * @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L1074 + * @param options - The options for loading the configuration file. + * @param options.root - The root directory for resolving imports. + * @param options.configPaths - An array of possible configuration file paths to check, relative to `root`. + * @param options.fs - The file system module to use for checking file existence. + * @returns A promise that resolves to the configuration object, or an empty object if no config file is found. */ -export async function importBundledFile({ - code, +export async function loadConfigFile({ + configPaths, root, - label = 'bundled-tmp.config', -}: ImportBundledFileArgs): Promise<{ default?: unknown }> { - // Write it to disk, load it with native Node ESM, then delete the file. - const tmpFileUrl = new URL( - `./${label}.timestamp-${Date.now()}-${Math.random().toString(36).substring(2, 9)}.mjs`, - root - ); - await writeFile(tmpFileUrl, code, { encoding: 'utf8' }); - try { - return await import(/* @vite-ignore */ tmpFileUrl.toString()); - } finally { - await tryCatch(unlink(tmpFileUrl)); - } -} - -/** - * Loads and bundles a configuration file, then imports the bundled module. - * - * @param params - The arguments for loading and bundling the config file. - * @param params.root - The root directory for resolving imports. - * @param params.fileUrl - The URL or path to the configuration file to load. - * @param params.label - A label used for logging or identification purposes. - * @returns A promise that resolves to an object containing the imported module (`mod`) - * and an array of its dependencies. - */ -export async function loadAndBundleConfigFile({ - root, - fileUrl, - label, -}: LoadAndBundleConfigFileArgs): Promise { - // If no file URL is provided, return an empty result - if (!fileUrl) { - return { mod: undefined }; - } - - // Bundle the configuration file and get its code and dependencies - const { code } = await bundleConfigFile({ - fileUrl, - }); - - // Import the bundled file using the provided root URL and optional label - return { - mod: await importBundledFile({ code, root, label }), - }; -} - -/** - * Loads a configuration file from a list of possible paths relative to a given root URL. - * - * Iterates through the provided `configPaths`, checking for the existence of each file. - * If a file is found, it is loaded and bundled, and its default export is returned as the configuration object. - * If no valid configuration file is found, or if the file does not have a valid default export, the function returns `undefined` - * or throws an error, respectively. - * - * @typeParam R - The expected shape of the configuration object. - * @param root - The base URL to resolve configuration file paths against. - * @param configPaths - An array of possible configuration file paths to check, relative to `root`. - * @returns A promise that resolves to the configuration object of type `R`, or `undefined` if no valid config file is found. - * @throws If a config file is found but does not have a valid default export. - */ -export async function loadConfigFile( - root: URL, - configPaths: string[], - label?: string -): Promise { + fs, +}: LoadConfigWithViteOptions): Promise> { let configFileUrl: URL | undefined; // Check each path in the configPaths array to see if the file exists @@ -123,7 +37,7 @@ export async function loadConfigFile( for (const path of configPaths) { const fileUrl = new URL(path, root); try { - await access(fileUrl, constants.F_OK); + await fs.promises.access(fileUrl, fs.constants.F_OK); configFileUrl = fileUrl; break; } catch { @@ -131,37 +45,40 @@ export async function loadConfigFile( } } - // If no config file was found, return undefined - // This is important to avoid unnecessary errors when no config file is present + // If no config file was found, return an empty object if (!configFileUrl) { - return undefined; + return {}; } - // Load and bundle the configuration file - // This will return the module and its dependencies - const { mod: configMod } = await loadAndBundleConfigFile({ - root, - fileUrl: configFileUrl, - label, - }); - - // If no module was returned, return undefined - // This is important to handle cases where the config file might be empty or invalid - if (!configMod) { - return undefined; + // Attempt to load config using Node's native ESM loader if the file has a .js, .cjs, or .mjs extension + if (/\.[cm]?js$/.test(configFileUrl.pathname)) { + try { + const config = await import(`${configFileUrl.toString()}?t=${Date.now()}`); + return config.default ?? {}; + } catch (e) { + if (e && typeof e === 'object' && 'code' in e && e.code === 'ERR_DLOPEN_DISABLED') { + throw e; + } + + console.debug('Failed to load config with Node', e); + } } - // Check if the module has a default export - // If not, throw an error indicating that the default export is missing or invalid - if (!configMod.default) { - throw new Error( - 'Missing or invalid default export. Please export your config object as the default export.' - ); - } + // Else try loading with Vite + let server: ViteDevServer | undefined; + try { + const plugins = loadFallbackPlugin({ fs, root }); + server = await createMinimalViteDevServer(plugins); - // Return the default export of the module, which is expected to be of type R - // This is the actual configuration object that will be used by the application - // The type assertion ensures that the returned object conforms to the expected shape of R - // This is important for type safety and to ensure that the configuration object has the expected properties - return configMod.default as R; + if (isRunnableDevEnvironment(server.environments.ssr)) { + const environment = server.environments.ssr; + const mod = await environment.runner.import(configFileUrl.toString()); + return mod.default ?? {}; + } + return {}; + } finally { + if (server) { + await server.close(); + } + } } diff --git a/packages/@withstudiocms/config-utils/src/types.ts b/packages/@withstudiocms/config-utils/src/types.ts index 4ee56927c5ee..250d46ba4201 100644 --- a/packages/@withstudiocms/config-utils/src/types.ts +++ b/packages/@withstudiocms/config-utils/src/types.ts @@ -1,40 +1,5 @@ import type { Schema } from 'effect'; -/** - * Arguments for importing a bundled file. - * - * @property code - The source code of the bundled file as a string. - * @property root - The root URL used as the base for resolving file paths. - * @property label - (Optional) A label for the temporary file, useful for identification or debugging. - */ -export interface ImportBundledFileArgs { - code: string; - root: URL; - label?: string; // Optional label for the temporary file -} - -/** - * Arguments for loading and bundling a configuration file. - * - * @property root - The root directory as a URL. - * @property fileUrl - The URL of the configuration file to load, or undefined if not specified. - * @property label - (Optional) A label for the temporary file, used for identification or logging. - */ -export interface LoadAndBundleConfigFileArgs { - root: URL; - fileUrl: URL | undefined; - label?: string; // Optional label for the temporary file -} - -/** - * The result of loading and bundling a configuration file. - * - * @property mod - The loaded module, which may contain a `default` export of any type, or be undefined if loading failed. - */ -export interface LoadAndBundleConfigFileResult { - mod: { default?: unknown } | undefined; -} - /** * Options for building an effect-based configuration resolver. * diff --git a/packages/@withstudiocms/config-utils/src/utils/createMinimalViteDevServer.ts b/packages/@withstudiocms/config-utils/src/utils/createMinimalViteDevServer.ts new file mode 100644 index 000000000000..5ac7564ca307 --- /dev/null +++ b/packages/@withstudiocms/config-utils/src/utils/createMinimalViteDevServer.ts @@ -0,0 +1,19 @@ +import { createServer, type Plugin, type ViteDevServer } from 'vite'; + +/** + * Creates a minimal dev server with a list of plugins. Use this instance for a one-shot usage. + * + * NOTE: This is intentionally in its own module to avoid pulling `vite`'s heavy `createServer` + * (and transitively Rollup) into every file that imports from `viteUtils.ts`. + */ +export async function createMinimalViteDevServer(plugins: Plugin[] = []): Promise { + return await createServer({ + configFile: false, + server: { middlewareMode: true, hmr: false, watch: null, ws: false }, + optimizeDeps: { noDiscovery: true }, + clearScreen: false, + appType: 'custom', + ssr: { external: true }, + plugins, + }); +} diff --git a/packages/@withstudiocms/config-utils/src/utils/dynamicResult.ts b/packages/@withstudiocms/config-utils/src/utils/dynamicResult.ts deleted file mode 100644 index 6f807f81fa93..000000000000 --- a/packages/@withstudiocms/config-utils/src/utils/dynamicResult.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * The Success type represents a successful result with a value of type T. - * It is used to indicate that an operation was successful and contains the result. - */ -export type Success = [T, null]; - -/** - * The Failure type represents a failure with an error of type E. - * It is used to indicate that an operation failed and contains the error. - */ -export type Failure = [null, E]; - -/** - * The Result type is a discriminated union that can either be a Success or a Failure. - * It is used to represent the outcome of an operation, where: - * - Success represents a successful result with a value of type T. - * - Failure represents a failure with an error of type E. - */ -export type Result = Success | Failure; diff --git a/packages/@withstudiocms/config-utils/src/utils/index.ts b/packages/@withstudiocms/config-utils/src/utils/index.ts deleted file mode 100644 index 019739870d51..000000000000 --- a/packages/@withstudiocms/config-utils/src/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './dynamicResult.js'; -export * from './tryCatch.js'; diff --git a/packages/@withstudiocms/config-utils/src/utils/tryCatch.ts b/packages/@withstudiocms/config-utils/src/utils/tryCatch.ts deleted file mode 100644 index f7456c8bf909..000000000000 --- a/packages/@withstudiocms/config-utils/src/utils/tryCatch.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Result } from './dynamicResult.js'; - -/** - * A utility function that wraps a promise or a synchronous function call in a try-catch block. - * It returns a tuple where the first element is the result (or null if an error occurred) - * and the second element is the error (or null if no error occurred). - * - * @param fn - The function to execute, which can be a promise or a synchronous function. - * @returns A promise that resolves to a tuple containing the result and error. - */ -export async function tryCatch(fn: () => T | Promise): Promise>; -export async function tryCatch(value: Promise): Promise>; - -export async function tryCatch( - fnOrValue: (() => T | Promise) | Promise -): Promise> { - try { - const result = typeof fnOrValue === 'function' ? fnOrValue() : fnOrValue; - - // If the result is a promise, wait for it to resolve - if (result instanceof Promise) { - const resolvedResult = await result; - return [resolvedResult, null]; - } - - // If the result is not a promise, return it directly - return [result, null]; - } catch (error) { - // If an error occurs, return null for the result - // and the error as the second element of the tuple - return [null, error as E]; - } -} diff --git a/packages/@withstudiocms/config-utils/src/utils/vite-plugin-load-fallback.ts b/packages/@withstudiocms/config-utils/src/utils/vite-plugin-load-fallback.ts new file mode 100644 index 000000000000..7a013ea3527e --- /dev/null +++ b/packages/@withstudiocms/config-utils/src/utils/vite-plugin-load-fallback.ts @@ -0,0 +1,94 @@ +import nodeFs from 'node:fs'; +import npath from 'node:path'; +import type * as vite from 'vite'; + +type NodeFileSystemModule = typeof nodeFs; + +interface LoadFallbackPluginParams { + fs?: NodeFileSystemModule; + root: URL; +} + +const FALLBACK_FLAG = 'astroFallbackFlag'; + +export function slash(path: string) { + return path.replace(/\\/g, '/'); +} + +const postfixRE = /[?#].*$/s; +export function cleanUrl(url: string): string { + return url.replace(postfixRE, ''); +} + +export default function loadFallbackPlugin({ fs, root }: LoadFallbackPluginParams): vite.Plugin[] { + // Only add this plugin if a custom fs implementation is provided. + // Also check for `fs.default` because `import * as fs from 'node:fs'` will + // export as so, which only it's `.default` would === `nodeFs`. + // @ts-expect-error check default + if (!fs || fs === nodeFs || fs.default === nodeFs) { + return []; + } + + const tryLoadModule = async (id: string) => { + try { + // await is necessary for the catch + return await fs.promises.readFile(cleanUrl(id), 'utf-8'); + } catch { + try { + return await fs.promises.readFile(id, 'utf-8'); + } catch { + try { + const fullpath = new URL(`.${id}`, root); + return await fs.promises.readFile(fullpath, 'utf-8'); + } catch { + // Let fall through to the next + } + } + } + }; + + return [ + { + name: 'withstudiocms-config-utils:load-fallback', + enforce: 'post', + async resolveId(id, parent) { + // See if this can be loaded from our fs + if (parent) { + const candidateId = npath.posix.join(npath.posix.dirname(slash(parent)), id); + try { + // Check to see if this file exists and is not a directory. + const stats = await fs.promises.stat(candidateId); + if (!stats.isDirectory()) { + const params = new URLSearchParams(FALLBACK_FLAG); + return `${candidateId}?${params.toString()}`; + } + } catch {} + } + }, + load: { + filter: { + id: new RegExp(`(?:\\?|&)${FALLBACK_FLAG}(?:&|=|$)`), + }, + async handler(id) { + const code = await tryLoadModule(id.slice(0, -(1 + FALLBACK_FLAG.length))); + if (code) { + return { code }; + } + }, + }, + }, + { + name: 'withstudiocms-config-utils:load-fallback-hmr', + enforce: 'pre', + handleHotUpdate(context) { + // Wrap context.read so it checks our filesystem first. + const read = context.read; + context.read = async () => { + const source = await tryLoadModule(context.file); + if (source) return source; + return read.call(context); + }; + }, + }, + ]; +} diff --git a/packages/@withstudiocms/config-utils/test/fixtures/loader-both/example.config.js b/packages/@withstudiocms/config-utils/test/fixtures/loader-both/example.config.js new file mode 100644 index 000000000000..a6465a9c75e5 --- /dev/null +++ b/packages/@withstudiocms/config-utils/test/fixtures/loader-both/example.config.js @@ -0,0 +1 @@ +export default { source: 'js' }; diff --git a/packages/@withstudiocms/config-utils/test/fixtures/loader-both/example.config.ts b/packages/@withstudiocms/config-utils/test/fixtures/loader-both/example.config.ts new file mode 100644 index 000000000000..0146acccc8f2 --- /dev/null +++ b/packages/@withstudiocms/config-utils/test/fixtures/loader-both/example.config.ts @@ -0,0 +1 @@ +export default { source: 'ts' }; diff --git a/packages/@withstudiocms/config-utils/test/fixtures/loader-js/example.config.js b/packages/@withstudiocms/config-utils/test/fixtures/loader-js/example.config.js new file mode 100644 index 000000000000..af5ebbe3898c --- /dev/null +++ b/packages/@withstudiocms/config-utils/test/fixtures/loader-js/example.config.js @@ -0,0 +1,7 @@ +export default { + name: 'Example Config', + description: 'This is an example config file for testing purposes.', + foo: 'bar', + verbose: true, + integer: 42, +}; diff --git a/packages/@withstudiocms/config-utils/test/fixtures/loader-neither/.gitkeep b/packages/@withstudiocms/config-utils/test/fixtures/loader-neither/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/@withstudiocms/config-utils/test/fixtures/loader-ts/example.config.ts b/packages/@withstudiocms/config-utils/test/fixtures/loader-ts/example.config.ts new file mode 100644 index 000000000000..af5ebbe3898c --- /dev/null +++ b/packages/@withstudiocms/config-utils/test/fixtures/loader-ts/example.config.ts @@ -0,0 +1,7 @@ +export default { + name: 'Example Config', + description: 'This is an example config file for testing purposes.', + foo: 'bar', + verbose: true, + integer: 42, +}; diff --git a/packages/@withstudiocms/config-utils/test/loader.test.ts b/packages/@withstudiocms/config-utils/test/loader.test.ts index a28e69b415c4..59b3c6565fee 100644 --- a/packages/@withstudiocms/config-utils/test/loader.test.ts +++ b/packages/@withstudiocms/config-utils/test/loader.test.ts @@ -1,345 +1,82 @@ -import { existsSync } from 'node:fs'; -import { mkdir, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import fs from 'node:fs'; import * as allure from 'allure-js-commons'; -import { afterAll, beforeAll, describe, expect, test } from 'vitest'; -import { - bundleConfigFile, - importBundledFile, - loadAndBundleConfigFile, - loadConfigFile, -} from '../src/loader.js'; +import { describe, expect, test } from 'vitest'; +import { loadConfigFile } from '../src/loader.js'; import { parentSuiteName, sharedTags } from './test-utils.js'; -const localSuiteName = 'Config Loader Tests'; +const localSuiteName = 'Config loader Tests'; -const testDir = join(tmpdir(), `config-loader-test-${Date.now()}`); -const testDirUrl = new URL(`file://${testDir}/`); +const exampleConfig = { + name: 'Example Config', + description: 'This is an example config file for testing purposes.', + foo: 'bar', + verbose: true, + integer: 42, +}; -if (existsSync(testDir)) { - throw new Error(`Test directory already exists: ${testDir}`); -} +const configPaths = ['example.config.js', 'example.config.ts']; describe(parentSuiteName, () => { - beforeAll(async () => { - await mkdir(testDir, { recursive: true }); - }); - - afterAll(async () => { - await rm(testDir, { recursive: true, force: true }); - }); - - test('Config Loader - bundleConfigFile', async () => { - await allure.parentSuite(parentSuiteName); - await allure.suite(localSuiteName); - await allure.subSuite('bundleConfigFile Tests'); - await allure.tags(...sharedTags); - - await allure.step('should bundle a simple javascript config file', async (ctx) => { - const configPath = join(testDir, 'simple.config.js'); - const configContent = ` - export default { - name: 'test-config', - value: 42 - }; - `; - - await ctx.parameter('configPath', configPath); - await ctx.parameter('configContent', configContent); - - await writeFile(configPath, configContent, 'utf8'); - - const result = await bundleConfigFile({ - fileUrl: new URL(`file://${configPath}`), + const fixtureTests = [ + { + rootUrl: new URL('./fixtures/loader-js/', import.meta.url), + name: 'loader-js', + }, + { + rootUrl: new URL('./fixtures/loader-ts/', import.meta.url), + name: 'loader-ts', + }, + ]; + + function loadConfig(root: URL) { + return loadConfigFile({ configPaths, fs, root }); + } + + fixtureTests.forEach(({ rootUrl, name }) => { + test(`Should load config from ${name}`, async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite(localSuiteName); + await allure.subSuite(`Load Config - ${name}`); + await allure.tags(...sharedTags); + + await allure.step(`Loading config from ${name}`, async (ctx) => { + ctx.parameter('rootUrl', rootUrl.toString()); + const config = await loadConfig(rootUrl); + ctx.parameter('loadedConfig', JSON.stringify(config, null, 2)); + expect(config).toEqual(exampleConfig); }); - - await ctx.parameter('bundledCode', result.code); - - expect(result.code).toBeTypeOf('string'); - expect(result.code).toContain('test-config'); - }); - - await allure.step('should bundle a typescript config file', async (ctx) => { - const configPath = join(testDir, 'typescript.config.ts'); - const configContent = ` - interface Config { - name: string; - value: number; - } - - const config: Config = { - name: 'typescript-config', - value: 123 - }; - - export default config; - `; - - await ctx.parameter('configPath', configPath); - await ctx.parameter('configContent', configContent); - - await writeFile(configPath, configContent, 'utf8'); - - const result = await bundleConfigFile({ - fileUrl: new URL(`file://${configPath}`), - }); - - await ctx.parameter('bundledCode', result.code); - - expect(result.code).toBeTypeOf('string'); - expect(result.code).toContain('typescript-config'); - }); - - await allure.step('should handle config with external imports', async (ctx) => { - const configPath = join(testDir, 'external.config.js'); - const configContent = ` - import { join } from 'node:path'; - - export default { - name: 'external-config', - testPath: join('test', 'path') - }; - `; - await ctx.parameter('configPath', configPath); - await ctx.parameter('configContent', configContent); - - await writeFile(configPath, configContent, 'utf8'); - - const result = await bundleConfigFile({ - fileUrl: new URL(`file://${configPath}`), - }); - - await ctx.parameter('bundledCode', result.code); - - expect(result.code).toBeTypeOf('string'); - expect(result.code).toContain('external-config'); }); }); - test('Config Loader - importBundledFile', async () => { + test('Should return {} when no candidate file exists in root', async () => { await allure.parentSuite(parentSuiteName); await allure.suite(localSuiteName); - await allure.subSuite('importBundledFile Tests'); + await allure.subSuite('Load Config - loader-neither'); await allure.tags(...sharedTags); - await allure.step('should import bundled code and return module', async (ctx) => { - const code = ` - export default { - imported: true, - timestamp: Date.now() - }; - `; - - await ctx.parameter('bundledCode', code); - - const result = await importBundledFile({ - code, - root: testDirUrl, - label: 'test-import', - }); - - await ctx.parameter('importedModule', JSON.stringify(result)); - - expect(result.default).toBeTruthy(); - // @ts-expect-error - Testing dynamic property - expect(result.default.imported).toBe(true); - // @ts-expect-error - Testing dynamic property - expect(typeof result.default.timestamp).toBe('number'); - }); - - await allure.step('should successfully import and handle cleanup', async (ctx) => { - const code = `export default { cleanup: 'test' };`; - await ctx.parameter('bundledCode', code); - - const result = await importBundledFile({ - code, - root: testDirUrl, - label: 'cleanup-test', - }); - await ctx.parameter('importedModule', JSON.stringify(result)); - - expect(result.default).toBeTruthy(); - // @ts-expect-error - Testing dynamic property - expect(result.default.cleanup).toBe('test'); + const rootUrl = new URL('./fixtures/loader-neither/', import.meta.url); + await allure.step('Loading config from loader-neither (no candidates present)', async (ctx) => { + ctx.parameter('rootUrl', rootUrl.toString()); + const config = await loadConfig(rootUrl); + ctx.parameter('loadedConfig', JSON.stringify(config, null, 2)); + expect(config).toEqual({}); }); }); - test('Config Loader - loadAndBundleConfigFile', async () => { + test('Should prefer first configPaths entry (.js) when both candidates exist', async () => { await allure.parentSuite(parentSuiteName); await allure.suite(localSuiteName); - await allure.subSuite('loadAndBundleConfigFile Tests'); + await allure.subSuite('Load Config - loader-both'); await allure.tags(...sharedTags); - await allure.step('should return empty result when no fileUrl is provided', async (ctx) => { - const result = await loadAndBundleConfigFile({ - root: testDirUrl, - fileUrl: undefined, - label: 'empty-test', - }); - - await ctx.parameter('resultModule', JSON.stringify(result.mod)); - - expect(result.mod).toBeUndefined(); - }); - - await allure.step('should load and bundle a config file', async (ctx) => { - const configPath = join(testDir, 'load-bundle.config.js'); - const configContent = ` - export default { - loaded: true, - bundled: true - }; - `; - await writeFile(configPath, configContent, 'utf8'); - const result = await loadAndBundleConfigFile({ - root: testDirUrl, - fileUrl: new URL(`file://${configPath}`), - label: 'load-bundle-test', - }); - - await ctx.parameter('resultModule', JSON.stringify(result.mod)); - - expect(result.mod).toBeTruthy(); - // @ts-expect-error - Testing dynamic property - expect(result.mod.default.loaded).toBe(true); - // @ts-expect-error - Testing dynamic property - expect(result.mod.default.bundled).toBe(true); - }); - }); - - test('Config Loader - loadConfigFile', async () => { - await allure.parentSuite(parentSuiteName); - await allure.suite(localSuiteName); - await allure.subSuite('loadConfigFile Tests'); - await allure.tags(...sharedTags); - - await allure.step('should return undefined when no config files exist', async (ctx) => { - const result = await loadConfigFile( - testDirUrl, - ['nonexistent1.config.js', 'nonexistent2.config.ts'], - 'missing-test' - ); - - await ctx.parameter('result', JSON.stringify(result)); - - expect(result).toBeUndefined(); - }); - - await allure.step('should load the first existing config file', async (ctx) => { - const config1Path = join(testDir, 'first.config.js'); - const config2Path = join(testDir, 'second.config.js'); - - const config1Content = `export default { order: 'first' };`; - const config2Content = `export default { order: 'second' };`; - - await writeFile(config1Path, config1Content, 'utf8'); - await writeFile(config2Path, config2Content, 'utf8'); - - const result = await loadConfigFile( - testDirUrl, - ['nonexistent.config.js', 'first.config.js', 'second.config.js'], - 'order-test' - ); - - await ctx.parameter('result', JSON.stringify(result)); - - expect(result).toBeTruthy(); - // @ts-expect-error - Testing dynamic property - expect(result.order).toBe('first'); - }); - - await allure.step('should throw error when config file has no default export', async (ctx) => { - const configPath = join(testDir, 'no-default.config.js'); - const configContent = ` - export const namedExport = { value: 'named' }; - // No default export - `; - await writeFile(configPath, configContent, 'utf8'); - - await ctx.parameter('configPath', configPath); - await ctx.parameter('configContent', configContent); - - await expect( - loadConfigFile(testDirUrl, ['no-default.config.js'], 'no-default-test') - ).rejects.toThrow( - 'Missing or invalid default export. Please export your config object as the default export.' - ); - }); - - await allure.step('should load TypeScript config file', async (ctx) => { - const configPath = join(testDir, 'typescript-load.config.ts'); - const configContent = ` - interface TestConfig { - name: string; - features: string[]; - } - - const config: TestConfig = { - name: 'typescript-test', - features: ['bundling', 'loading', 'typescript'] - }; - - export default config; - `; - await writeFile(configPath, configContent, 'utf8'); - - const result = await loadConfigFile(testDirUrl, ['typescript-load.config.ts'], 'ts-test'); - - await ctx.parameter('result', JSON.stringify(result)); - - expect(result).toBeTruthy(); - // @ts-expect-error - Testing dynamic property - expect(result.name).toBe('typescript-test'); - // @ts-expect-error - Testing dynamic property - expect(Array.isArray(result.features)).toBe(true); - // @ts-expect-error - Testing dynamic property - expect(result.features).toContain('typescript'); - }); - - await allure.step('should load config file with complex exports', async (ctx) => { - const configPath = join(testDir, 'complex-load.config.js'); - const configContent = ` - const baseConfig = { - environment: 'production', - database: { - host: 'db.example.com', - port: 3306 - } - }; - const features = ['feature1', 'feature2']; - export default { - ...baseConfig, - features, - computed: features.length, - timestamp: Date.now() - }; - `; - await writeFile(configPath, configContent, 'utf8'); - - const result = await loadConfigFile( - testDirUrl, - ['complex-load.config.js'], - 'complex-load-test' - ); - - await ctx.parameter('result', JSON.stringify(result)); - - expect(result).toBeTruthy(); - // @ts-expect-error - Testing dynamic property - expect(result.environment).toBe('production'); - // @ts-expect-error - Testing dynamic property - expect(result.database.host).toBe('db.example.com'); - // @ts-expect-error - Testing dynamic property - expect(result.database.port).toBe(3306); - // @ts-expect-error - Testing dynamic property - expect(Array.isArray(result.features)).toBe(true); - // @ts-expect-error - Testing dynamic property - expect(result.features).toContain('feature1'); - // @ts-expect-error - Testing dynamic property - expect(result.computed).toBe(2); - // @ts-expect-error - Testing dynamic property - expect(typeof result.timestamp).toBe('number'); + const rootUrl = new URL('./fixtures/loader-both/', import.meta.url); + await allure.step('Loading config from loader-both (both .js and .ts present)', async (ctx) => { + ctx.parameter('rootUrl', rootUrl.toString()); + const config = await loadConfig(rootUrl); + ctx.parameter('loadedConfig', JSON.stringify(config, null, 2)); + // configPaths = ['example.config.js', 'example.config.ts'] + // .js is listed first so the loader must break on it and never reach .ts + expect(config).toHaveProperty('source', 'js'); }); }); }); diff --git a/packages/@withstudiocms/config-utils/test/utils/tryCatch.test.ts b/packages/@withstudiocms/config-utils/test/utils/tryCatch.test.ts deleted file mode 100644 index 5a77918c3fe7..000000000000 --- a/packages/@withstudiocms/config-utils/test/utils/tryCatch.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import * as allure from 'allure-js-commons'; -import { describe, expect, test } from 'vitest'; -import { tryCatch } from '../../src/utils/index.js'; -import { parentSuiteName, sharedTags } from '../test-utils.js'; - -const localSuiteName = 'TryCatch Tests'; - -describe(parentSuiteName, () => { - [ - { - fn: () => 42, - result: 42, - error: null, - }, - { - fn: () => { - throw new Error('Test error'); - }, - result: null, - error: new Error('Test error'), - }, - { - fn: () => 'hello', - result: 'hello', - error: null, - }, - { - fn: () => ({ foo: 'bar' }), - result: { foo: 'bar' }, - error: null, - }, - { - fn: () => [1, 2, 3], - result: [1, 2, 3], - error: null, - }, - { - fn: () => null, - result: null, - error: null, - }, - { - fn: () => undefined, - result: undefined, - error: null, - }, - { - fn: async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - return 'async result'; - }, - result: 'async result', - error: null, - }, - { - fn: async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - throw new Error('Async error'); - }, - result: null, - error: new Error('Async error'), - }, - ].forEach(({ fn, result, error }) => { - test('TryCatch - Functionality Tests', async () => { - await allure.parentSuite(parentSuiteName); - await allure.suite(localSuiteName); - await allure.subSuite('Functionality Test Set'); - await allure.tags(...sharedTags); - - // @ts-expect-error: Testing dynamic function inputs - const [res, err] = await tryCatch(fn); - - await allure.step('Should return expected result', async (ctx) => { - ctx.parameter('expectedResult', JSON.stringify(result)); - ctx.parameter('actualResult', JSON.stringify(res)); - expect(res).toEqual(result); - }); - - await allure.step('Should return expected error', async (ctx) => { - ctx.parameter('expectedError', error ? error.toString() : 'null'); - ctx.parameter('actualError', err ? err.toString() : 'null'); - if (error === null) { - expect(err).toBeNull(); - } else { - expect(err).toBeInstanceOf(Error); - expect(err?.message).toBe(error.message); - } - }); - }); - }); -}); diff --git a/packages/studiocms/src/cli/debug/index.ts b/packages/studiocms/src/cli/debug/index.ts index abbd820799fa..28a255b03932 100644 --- a/packages/studiocms/src/cli/debug/index.ts +++ b/packages/studiocms/src/cli/debug/index.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import { pathToFileURL } from 'node:url'; import { StudioCMSColorwayBg } from '@withstudiocms/cli-kit/colors'; import { label } from '@withstudiocms/cli-kit/messages'; @@ -42,11 +43,11 @@ const loadConfig = ( const cwd = process.cwd(); const rootURL = pathToFileURL(`${cwd}/`); const configPaths = key === 'astro' ? astroConfigPaths : studiocmsConfigPaths; - return await loadConfigFile( - rootURL, + return await loadConfigFile({ + root: rootURL, configPaths, - key - ); + fs, + }); }, catch: (error) => { throw new Error(`Failed to load config: ${(error as Error).message}`); diff --git a/packages/studiocms/src/cli/utils/loadConfig.ts b/packages/studiocms/src/cli/utils/loadConfig.ts index 8e57d1c163e0..38e16f77d093 100644 --- a/packages/studiocms/src/cli/utils/loadConfig.ts +++ b/packages/studiocms/src/cli/utils/loadConfig.ts @@ -1,7 +1,8 @@ +import fs from 'node:fs'; import { loadConfigFile as _loadConfigFile } from '@withstudiocms/config-utils'; import { Effect, Schema } from 'effect'; import { configPaths } from '../../consts.js'; -import { type StudioCMSOptions, StudioCMSOptionsSchema } from '../../schemas/index.js'; +import { StudioCMSOptionsSchema } from '../../schemas/index.js'; /** * Loads the StudioCMS configuration file from the specified root directory. @@ -10,7 +11,7 @@ import { type StudioCMSOptions, StudioCMSOptionsSchema } from '../../schemas/ind * @returns An Effect that resolves to the loaded StudioCMSOptions or undefined if no config file is found. */ const loadConfigFile = Effect.fn((root: URL) => - Effect.tryPromise(() => _loadConfigFile(root, configPaths, 'migrator')) + Effect.tryPromise(() => _loadConfigFile({ configPaths, root, fs })) ); /** @@ -19,7 +20,7 @@ const loadConfigFile = Effect.fn((root: URL) => * @param config - The configuration object to parse. * @returns An Effect that resolves to the parsed StudioCMSOptions. */ -const parse = Effect.fn((config: StudioCMSOptions | undefined) => +const parse = Effect.fn((config: Record | undefined) => Schema.decode(StudioCMSOptionsSchema)(config ?? {}) ); diff --git a/packages/studiocms/src/db/plugins.ts b/packages/studiocms/src/db/plugins.ts index bf40b7c34d52..2a6abe4f0993 100644 --- a/packages/studiocms/src/db/plugins.ts +++ b/packages/studiocms/src/db/plugins.ts @@ -1,9 +1,10 @@ +import fs from 'node:fs'; import { loadConfigFile as _loadConfigFile } from '@withstudiocms/config-utils'; import { getDBClientLive } from '@withstudiocms/kysely/client'; import type { StudioCMSDatabaseSchema } from '@withstudiocms/sdk/tables'; import { configPaths } from '../consts.js'; import { Effect, Schema } from '../effect.js'; -import { type StudioCMSOptions, StudioCMSOptionsSchema } from '../schemas/index.js'; +import { StudioCMSOptionsSchema } from '../schemas/index.js'; import { type DbDialectType, getDbDriver, parseDbDialect } from './index.js'; export * from '@withstudiocms/kysely/plugin'; @@ -12,15 +13,13 @@ export * from '@withstudiocms/kysely/plugin'; * Load the StudioCMS configuration from the specified root URL */ const loadConfigFile = Effect.fn((root: URL) => - Effect.tryPromise(() => - _loadConfigFile(root, configPaths, 'studiocms-db-plugin') - ) + Effect.tryPromise(() => _loadConfigFile({ configPaths, root, fs })) ); /** * Parse the loaded configuration with the default configuration */ -const parse = Effect.fn((config: StudioCMSOptions | undefined) => +const parse = Effect.fn((config: Record | undefined) => Schema.decode(StudioCMSOptionsSchema)(config ?? {}) ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23387b8a6d04..f8012732d126 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -926,9 +926,6 @@ importers: effect: specifier: catalog:effect version: 3.21.4 - tsdown: - specifier: 'catalog:' - version: 0.22.5(oxc-resolver@11.23.0)(publint@0.3.21)(tsx@4.23.0)(typescript@6.0.3) devDependencies: '@types/node': specifier: 'catalog:' @@ -936,6 +933,9 @@ importers: astro: specifier: catalog:astrojs-current version: 6.4.8(@types/node@22.20.1)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.23.0)(yaml@2.9.0) + vite: + specifier: catalog:astrojs-current + version: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) packages/@withstudiocms/effect: dependencies: