diff --git a/packages/kit/src/cli.js b/packages/kit/src/cli.js index f80e01a461b2..e09d9711a858 100644 --- a/packages/kit/src/cli.js +++ b/packages/kit/src/cli.js @@ -2,7 +2,7 @@ import { existsSync } from 'fs'; import sade from 'sade'; import colors from 'kleur'; import * as ports from 'port-authority'; -import { load_config } from './core/load_config/index.js'; +import { load_config } from './core/config/index.js'; import { networkInterfaces, release } from 'os'; async function get_config() { diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js index e5c6f4c2ffd6..8a1664b9331e 100644 --- a/packages/kit/src/core/build/index.js +++ b/packages/kit/src/core/build/index.js @@ -3,6 +3,7 @@ import path from 'path'; import { rimraf } from '../filesystem/index.js'; import create_manifest_data from '../../core/create_manifest_data/index.js'; import { copy_assets, get_no_external, posixify, resolve_entry } from '../utils.js'; +import { deep_merge, print_config_conflicts } from '../config/index.js'; import { create_app } from '../../core/create_app/index.js'; import vite from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; @@ -134,19 +135,17 @@ async function build_client({ /** @type {any} */ const user_config = config.kit.vite(); - await vite.build({ - ...user_config, + /** @type {[any, string[]]} */ + const [merged_config, conflicts] = deep_merge(user_config, { configFile: false, root: cwd, base, build: { - ...user_config.build, cssCodeSplit: true, manifest: true, outDir: client_out_dir, polyfillDynamicImport: false, rollupOptions: { - ...(user_config.build && user_config.build.rollupOptions), input, output: { entryFileNames: '[name]-[hash].js', @@ -157,15 +156,12 @@ async function build_client({ } }, resolve: { - ...user_config.resolve, alias: { - ...(user_config.resolve && user_config.resolve.alias), $app: path.resolve(`${build_dir}/runtime/app`), $lib: config.kit.files.lib } }, plugins: [ - ...(user_config.plugins || []), svelte({ extensions: config.extensions, emitCss: !config.kit.amp @@ -173,6 +169,10 @@ async function build_client({ ] }); + print_config_conflicts(conflicts, 'kit.vite.', 'build_client'); + + await vite.build(merged_config); + /** @type {ClientManifest} */ const client_manifest = JSON.parse(fs.readFileSync(client_manifest_file, 'utf-8')); fs.renameSync(client_manifest_file, `${output_dir}/manifest.json`); // inspectable but not shipped @@ -395,19 +395,17 @@ async function build_server( /** @type {any} */ const user_config = config.kit.vite(); - await vite.build({ - ...user_config, + /** @type {[any, string[]]} */ + const [merged_config, conflicts] = deep_merge(user_config, { configFile: false, root: cwd, base, build: { target: 'es2018', - ...user_config.build, ssr: true, outDir: `${output_dir}/server`, polyfillDynamicImport: false, rollupOptions: { - ...(user_config.build && user_config.build.rollupOptions), input: { app: app_file }, @@ -422,15 +420,12 @@ async function build_server( } }, resolve: { - ...user_config.resolve, alias: { - ...(user_config.resolve && user_config.resolve.alias), $app: path.resolve(`${build_dir}/runtime/app`), $lib: config.kit.files.lib } }, plugins: [ - ...(user_config.plugins || []), svelte({ extensions: config.extensions }) @@ -440,7 +435,6 @@ async function build_server( // so we need to ignore the fact that it's missing // @ts-ignore ssr: { - ...user_config.ssr, // note to self: this _might_ need to be ['svelte', '@sveltejs/kit', ...get_no_external()] // but I'm honestly not sure. roll with this for now and see if it's ok noExternal: get_no_external(cwd, user_config.ssr && user_config.ssr.noExternal) @@ -449,6 +443,10 @@ async function build_server( entries: [] } }); + + print_config_conflicts(conflicts, 'kit.vite.', 'build_server'); + + await vite.build(merged_config); } /** @@ -504,20 +502,18 @@ async function build_service_worker( /** @type {any} */ const user_config = config.kit.vite(); - await vite.build({ - ...user_config, + /** @type {[any, string[]]} */ + const [merged_config, conflicts] = deep_merge(user_config, { configFile: false, root: cwd, base, build: { - ...user_config.build, lib: { entry: service_worker_entry_file, name: 'app', formats: ['es'] }, rollupOptions: { - ...(user_config.build && user_config.build.rollupOptions), output: { entryFileNames: 'service-worker.js' } @@ -526,9 +522,7 @@ async function build_service_worker( emptyOutDir: false }, resolve: { - ...user_config.resolve, alias: { - ...(user_config.resolve && user_config.resolve.alias), '$service-worker': path.resolve(`${build_dir}/runtime/service-worker`) } }, @@ -536,6 +530,10 @@ async function build_service_worker( entries: [] } }); + + print_config_conflicts(conflicts, 'kit.vite.', 'build_service_worker'); + + await vite.build(merged_config); } /** @param {string[]} array */ diff --git a/packages/kit/src/core/load_config/index.js b/packages/kit/src/core/config/index.js similarity index 63% rename from packages/kit/src/core/load_config/index.js rename to packages/kit/src/core/config/index.js index 7973a6fc8a3f..f3a0ab0f6f91 100644 --- a/packages/kit/src/core/load_config/index.js +++ b/packages/kit/src/core/config/index.js @@ -2,6 +2,7 @@ import options from './options.js'; import * as url from 'url'; import path from 'path'; import fs from 'fs'; +import { logger } from '../utils.js'; /** @typedef {import('./types').ConfigDefinition} ConfigDefinition */ @@ -145,3 +146,76 @@ export function validate_config(config) { return validated; } + +/** + * Merges b into a, recursively, mutating a. + * @param {Record} a + * @param {Record} b + * @param {string[]} conflicts array to accumulate conflicts in + * @param {string[]} path array of property names representing the current + * location in the tree + */ +function merge_into(a, b, conflicts = [], path = []) { + /** + * @param {any} x + */ + const is_object = (x) => typeof x === 'object' && !Array.isArray(x); + + for (const prop in b) { + if (is_object(b[prop])) { + if (!is_object(a[prop])) { + if (a[prop] !== undefined) { + conflicts.push([...path, prop].join('.')); + } + a[prop] = {}; + } + merge_into(a[prop], b[prop], conflicts, [...path, prop]); + } else if (Array.isArray(b[prop])) { + if (!Array.isArray(a[prop])) { + if (a[prop] !== undefined) { + conflicts.push([...path, prop].join('.')); + } + a[prop] = []; + } + a[prop].push(...b[prop]); + } else { + if (a[prop] !== undefined) { + conflicts.push([...path, prop].join('.')); + } + a[prop] = b[prop]; + } + } +} + +/** + * Takes zero or more objects and returns a new object that has all the values + * deeply merged together. None of the original objects will be mutated at any + * level, and the returned object will have no references to the original + * objects at any depth. If there's a conflict the last one wins, except for + * arrays which will be combined. + * @param {...Object} objects + * @returns {[Record, string[]]} a 2-tuple with the merged object, + * and a list of merge conflicts if there were any, in dotted notation + */ +export function deep_merge(...objects) { + const result = {}; + /** @type {string[]} */ + const conflicts = []; + objects.forEach((o) => merge_into(result, o, conflicts)); + return [result, conflicts]; +} + +/** + * @param {string[]} conflicts - array of conflicts in dotted notation + * @param {string=} pathPrefix - prepended in front of the path + * @param {string=} scope - used to prefix the whole error message + */ +export function print_config_conflicts(conflicts, pathPrefix = '', scope) { + const prefix = scope ? scope + ': ' : ''; + const log = logger({ verbose: false }); + conflicts.forEach((conflict) => { + log.error( + `${prefix}The value for ${pathPrefix}${conflict} specified in svelte.config.js has been ignored. This option is controlled by SvelteKit.` + ); + }); +} diff --git a/packages/kit/src/core/load_config/index.spec.js b/packages/kit/src/core/config/index.spec.js similarity index 66% rename from packages/kit/src/core/load_config/index.spec.js rename to packages/kit/src/core/config/index.spec.js index 6f11b28d79c3..bd53d4f6e6b2 100644 --- a/packages/kit/src/core/load_config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -1,6 +1,6 @@ -import { test } from 'uvu'; +import { test, suite } from 'uvu'; import * as assert from 'uvu/assert'; -import { validate_config } from './index.js'; +import { validate_config, deep_merge } from './index.js'; test('fills in defaults', () => { const validated = validate_config({}); @@ -298,3 +298,152 @@ validate_paths( ); test.run(); + +const deepMergeSuite = suite('deep_merge'); + +deepMergeSuite('basic test no conflicts', async () => { + const [merged, conflicts] = deep_merge( + { + version: 1, + animalSounds: { + cow: 'moo' + } + }, + { + animalSounds: { + duck: 'quack' + }, + locale: 'en_US' + } + ); + assert.equal(merged, { + version: 1, + locale: 'en_US', + animalSounds: { + cow: 'moo', + duck: 'quack' + } + }); + assert.equal(conflicts, []); +}); + +deepMergeSuite('three way merge no conflicts', async () => { + const [merged, conflicts] = deep_merge( + { + animalSounds: { + cow: 'moo' + } + }, + { + animalSounds: { + duck: 'quack' + } + }, + { + animalSounds: { + dog: { + singular: 'bark', + plural: 'barks' + } + } + } + ); + assert.equal(merged, { + animalSounds: { + cow: 'moo', + duck: 'quack', + dog: { + singular: 'bark', + plural: 'barks' + } + } + }); + assert.equal(conflicts, []); +}); + +deepMergeSuite('merge with conflicts', async () => { + const [merged, conflicts] = deep_merge( + { + person: { + firstName: 'John', + lastName: 'Doe', + address: { + line1: '123 Main St', + city: 'Seattle', + state: 'WA' + } + } + }, + { + person: { + middleInitial: 'Q', + address: '123 Main St, Seattle, WA' + } + } + ); + assert.equal(merged, { + person: { + firstName: 'John', + middleInitial: 'Q', + lastName: 'Doe', + address: '123 Main St, Seattle, WA' + } + }); + assert.equal(conflicts, ['person.address']); +}); + +deepMergeSuite('merge with arrays', async () => { + const [merged] = deep_merge( + { + paths: ['/foo', '/bar'] + }, + { + paths: ['/alpha', '/beta'] + } + ); + assert.equal(merged, { + paths: ['/foo', '/bar', '/alpha', '/beta'] + }); +}); + +deepMergeSuite('empty', async () => { + const [merged] = deep_merge(); + assert.equal(merged, {}); +}); + +deepMergeSuite('mutability safety', () => { + const input1 = { + person: { + firstName: 'John', + lastName: 'Doe', + address: { + line1: '123 Main St', + city: 'Seattle' + } + } + }; + const input2 = { + person: { + middleInitial: 'L', + lastName: 'Smith', + address: { + state: 'WA' + } + } + }; + const snapshot1 = JSON.stringify(input1); + const snapshot2 = JSON.stringify(input2); + + const [merged] = deep_merge(input1, input2); + + // Mess with the result + merged.person.middleInitial = 'Z'; + merged.person.address.zipCode = '98103'; + merged.person = {}; + + // Make sure nothing in the inputs changed + assert.snapshot(snapshot1, JSON.stringify(input1)); + assert.snapshot(snapshot2, JSON.stringify(input2)); +}); + +deepMergeSuite.run(); diff --git a/packages/kit/src/core/load_config/options.js b/packages/kit/src/core/config/options.js similarity index 100% rename from packages/kit/src/core/load_config/options.js rename to packages/kit/src/core/config/options.js diff --git a/packages/kit/src/core/load_config/test/fixtures/default-cjs/src/app.html b/packages/kit/src/core/config/test/fixtures/default-cjs/src/app.html similarity index 100% rename from packages/kit/src/core/load_config/test/fixtures/default-cjs/src/app.html rename to packages/kit/src/core/config/test/fixtures/default-cjs/src/app.html diff --git a/packages/kit/src/core/load_config/test/fixtures/default-cjs/svelte.config.cjs b/packages/kit/src/core/config/test/fixtures/default-cjs/svelte.config.cjs similarity index 100% rename from packages/kit/src/core/load_config/test/fixtures/default-cjs/svelte.config.cjs rename to packages/kit/src/core/config/test/fixtures/default-cjs/svelte.config.cjs diff --git a/packages/kit/src/core/load_config/test/fixtures/default-esm/src/app.html b/packages/kit/src/core/config/test/fixtures/default-esm/src/app.html similarity index 100% rename from packages/kit/src/core/load_config/test/fixtures/default-esm/src/app.html rename to packages/kit/src/core/config/test/fixtures/default-esm/src/app.html diff --git a/packages/kit/src/core/load_config/test/fixtures/default-esm/svelte.config.js b/packages/kit/src/core/config/test/fixtures/default-esm/svelte.config.js similarity index 100% rename from packages/kit/src/core/load_config/test/fixtures/default-esm/svelte.config.js rename to packages/kit/src/core/config/test/fixtures/default-esm/svelte.config.js diff --git a/packages/kit/src/core/load_config/test/index.js b/packages/kit/src/core/config/test/index.js similarity index 100% rename from packages/kit/src/core/load_config/test/index.js rename to packages/kit/src/core/config/test/index.js diff --git a/packages/kit/src/core/load_config/types.d.ts b/packages/kit/src/core/config/types.d.ts similarity index 100% rename from packages/kit/src/core/load_config/types.d.ts rename to packages/kit/src/core/config/types.d.ts diff --git a/packages/kit/src/core/dev/index.js b/packages/kit/src/core/dev/index.js index 7c1dc4953777..7bbb20e7454a 100644 --- a/packages/kit/src/core/dev/index.js +++ b/packages/kit/src/core/dev/index.js @@ -12,6 +12,7 @@ import { rimraf } from '../filesystem/index.js'; import { respond } from '../../runtime/server/index.js'; import { getRawBody } from '../node/index.js'; import { copy_assets, get_no_external, resolve_entry } from '../utils.js'; +import { deep_merge, print_config_conflicts } from '../config/index.js'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import { get_server } from '../server/index.js'; import '../../install-fetch.js'; @@ -86,18 +87,13 @@ class Watcher extends EventEmitter { const alias = user_config.resolve && user_config.resolve.alias; - /** - * @type {vite.ViteDevServer} - */ - this.vite = await vite.createServer({ - ...user_config, + /** @type {[any, string[]]} */ + const [merged_config, conflicts] = deep_merge(user_config, { configFile: false, root: this.cwd, resolve: { - ...user_config.resolve, alias: Array.isArray(alias) ? [ - ...alias, { find: '$app', replacement: path.resolve(`${this.dir}/runtime/app`) @@ -108,13 +104,11 @@ class Watcher extends EventEmitter { } ] : { - ...alias, $app: path.resolve(`${this.dir}/runtime/app`), $lib: this.config.kit.files.lib } }, plugins: [ - ...(user_config.plugins || []), svelte({ extensions: this.config.extensions, emitCss: !this.config.kit.amp @@ -122,23 +116,26 @@ class Watcher extends EventEmitter { ], publicDir: this.config.kit.files.assets, server: { - ...user_config.server, middlewareMode: true, hmr: { - ...(user_config.server && user_config.server.hmr), ...(this.https ? { server: this.server, port: this.port } : {}) } }, optimizeDeps: { - ...user_config.optimizeDeps, entries: [] }, ssr: { - ...user_config.ssr, noExternal: get_no_external(this.cwd, user_config.ssr && user_config.ssr.noExternal) } }); + print_config_conflicts(conflicts, 'kit.vite.'); + + /** + * @type {vite.ViteDevServer} + */ + this.vite = await vite.createServer(merged_config); + const validator = this.config.kit.amp && (await amp_validator.getInstance()); /** diff --git a/packages/kit/test/test.js b/packages/kit/test/test.js index 78c4a3ae62d8..b70cd5876d2e 100644 --- a/packages/kit/test/test.js +++ b/packages/kit/test/test.js @@ -6,7 +6,7 @@ import { chromium } from 'playwright-chromium'; import { dev } from '../src/core/dev/index.js'; import { build } from '../src/core/build/index.js'; import { start } from '../src/core/start/index.js'; -import { load_config } from '../src/core/load_config/index.js'; +import { load_config } from '../src/core/config/index.js'; import { fileURLToPath, pathToFileURL } from 'url'; import { format } from 'util';