UNPKG

22.8 kBJavaScriptView Raw
1import { cosmiconfigSync } from 'cosmiconfig';
2import { all as merge } from 'deepmerge';
3import { validate } from 'jsonschema';
4import * as colors from 'kleur/colors';
5import path from 'path';
6import yargs from 'yargs-parser';
7import { esbuildPlugin } from './commands/esbuildPlugin';
8import { DEV_DEPENDENCIES_DIR } from './util';
9const CONFIG_NAME = 'snowpack';
10const ALWAYS_EXCLUDE = ['**/node_modules/**/*', '**/.types/**/*'];
11const SCRIPT_TYPES_WEIGHTED = {
12 proxy: 1,
13 mount: 2,
14 run: 3,
15 build: 4,
16 bundle: 100,
17};
18// default settings
19const DEFAULT_CONFIG = {
20 exclude: ['__tests__/**/*', '**/*.@(spec|test).*'],
21 plugins: [],
22 installOptions: {
23 dest: 'web_modules',
24 externalPackage: [],
25 installTypes: false,
26 env: {},
27 alias: {},
28 namedExports: [],
29 rollup: {
30 plugins: [],
31 dedupe: [],
32 },
33 },
34 devOptions: {
35 secure: false,
36 port: 8080,
37 open: 'default',
38 out: 'build',
39 fallback: 'index.html',
40 hmr: true,
41 bundle: undefined,
42 },
43 buildOptions: {
44 baseUrl: '/',
45 metaDir: '__snowpack__',
46 },
47};
48const configSchema = {
49 type: 'object',
50 properties: {
51 extends: { type: 'string' },
52 install: { type: 'array', items: { type: 'string' } },
53 exclude: { type: 'array', items: { type: 'string' } },
54 plugins: { type: 'array' },
55 webDependencies: {
56 type: ['object'],
57 additionalProperties: { type: 'string' },
58 },
59 scripts: {
60 type: ['object'],
61 additionalProperties: { type: 'string' },
62 },
63 devOptions: {
64 type: 'object',
65 properties: {
66 secure: { type: 'boolean' },
67 port: { type: 'number' },
68 out: { type: 'string' },
69 fallback: { type: 'string' },
70 bundle: { type: 'boolean' },
71 open: { type: 'string' },
72 hmr: { type: 'boolean' },
73 },
74 },
75 installOptions: {
76 type: 'object',
77 properties: {
78 dest: { type: 'string' },
79 externalPackage: { type: 'array', items: { type: 'string' } },
80 treeshake: { type: 'boolean' },
81 installTypes: { type: 'boolean' },
82 sourceMap: { oneOf: [{ type: 'boolean' }, { type: 'string' }] },
83 alias: {
84 type: 'object',
85 additionalProperties: { type: 'string' },
86 },
87 env: {
88 type: 'object',
89 additionalProperties: {
90 oneOf: [
91 { id: 'EnvVarString', type: 'string' },
92 { id: 'EnvVarNumber', type: 'number' },
93 { id: 'EnvVarTrue', type: 'boolean', enum: [true] },
94 ],
95 },
96 },
97 rollup: {
98 type: 'object',
99 properties: {
100 plugins: { type: 'array', items: { type: 'object' } },
101 dedupe: {
102 type: 'array',
103 items: { type: 'string' },
104 },
105 },
106 },
107 },
108 },
109 buildOptions: {
110 type: ['object'],
111 properties: {
112 baseUrl: { type: 'string' },
113 metaDir: { type: 'string' },
114 },
115 },
116 proxy: {
117 type: 'object',
118 },
119 },
120};
121/**
122 * Convert CLI flags to an incomplete Snowpack config representation.
123 * We need to be careful about setting properties here if the flag value
124 * is undefined, since the deep merge strategy would then overwrite good
125 * defaults with 'undefined'.
126 */
127function expandCliFlags(flags) {
128 const result = {
129 installOptions: {},
130 devOptions: {},
131 buildOptions: {},
132 };
133 const { help, version, reload, config, ...relevantFlags } = flags;
134 for (const [flag, val] of Object.entries(relevantFlags)) {
135 if (flag === '_' || flag.includes('-')) {
136 continue;
137 }
138 if (configSchema.properties[flag]) {
139 result[flag] = val;
140 continue;
141 }
142 if (configSchema.properties.installOptions.properties[flag]) {
143 result.installOptions[flag] = val;
144 continue;
145 }
146 if (configSchema.properties.devOptions.properties[flag]) {
147 result.devOptions[flag] = val;
148 continue;
149 }
150 console.error(`Unknown CLI flag: "${flag}"`);
151 process.exit(1);
152 }
153 if (result.installOptions.env) {
154 result.installOptions.env = result.installOptions.env.reduce((acc, id) => {
155 const index = id.indexOf('=');
156 const [key, val] = index > 0 ? [id.substr(0, index), id.substr(index + 1)] : [id, true];
157 acc[key] = val;
158 return acc;
159 }, {});
160 }
161 return result;
162}
163/**
164 * Convert deprecated proxy scripts to
165 * FUTURE: Remove this on next major release
166 */
167function handleLegacyProxyScripts(config) {
168 for (const scriptId in config.scripts) {
169 if (!scriptId.startsWith('proxy:')) {
170 continue;
171 }
172 const cmdArr = config.scripts[scriptId].split(/\s+/);
173 if (cmdArr[0] !== 'proxy') {
174 handleConfigError(`scripts[${scriptId}] must use the proxy command`);
175 }
176 cmdArr.shift();
177 const { to, _ } = yargs(cmdArr);
178 if (_.length !== 1) {
179 handleConfigError(`scripts[${scriptId}] must use the format: "proxy http://SOME.URL --to /PATH"`);
180 }
181 if (to && to[0] !== '/') {
182 handleConfigError(`scripts[${scriptId}]: "--to ${to}" must be a URL path, and start with a "/"`);
183 }
184 const { toUrl, fromUrl } = { fromUrl: _[0], toUrl: to };
185 if (config.proxy[toUrl]) {
186 handleConfigError(`scripts[${scriptId}]: Cannot overwrite proxy[${toUrl}].`);
187 }
188 config.proxy[toUrl] = fromUrl;
189 delete config.scripts[scriptId];
190 }
191 return config;
192}
193function normalizeScripts(scripts) {
194 function prefixDot(file) {
195 return `.${file}`.replace(/^\.+/, '.'); // prefix with dot, and make sure only one sticks
196 }
197 const processedScripts = [];
198 if (Object.keys(scripts).filter((k) => k.startsWith('bundle:')).length > 1) {
199 handleConfigError(`scripts can only contain 1 script of type "bundle:".`);
200 }
201 for (const scriptId of Object.keys(scripts)) {
202 if (scriptId.includes('::watch')) {
203 continue;
204 }
205 const [scriptType, scriptMatch] = scriptId.split(':');
206 if (!SCRIPT_TYPES_WEIGHTED[scriptType]) {
207 handleConfigError(`scripts[${scriptId}]: "${scriptType}" is not a known script type.`);
208 }
209 const cmd = scripts[scriptId];
210 const newScriptConfig = {
211 id: scriptId,
212 type: scriptType,
213 match: scriptMatch.split(',').map(prefixDot),
214 cmd,
215 watch: scripts[`${scriptId}::watch`],
216 };
217 if (newScriptConfig.watch) {
218 newScriptConfig.watch = newScriptConfig.watch.replace('$1', newScriptConfig.cmd);
219 }
220 if (scriptType === 'mount') {
221 const cmdArr = cmd.split(/\s+/);
222 if (cmdArr[0] !== 'mount') {
223 handleConfigError(`scripts[${scriptId}] must use the mount command`);
224 }
225 cmdArr.shift();
226 const { to, _ } = yargs(cmdArr);
227 if (_.length !== 1) {
228 handleConfigError(`scripts[${scriptId}] must use the format: "mount dir [--to /PATH]"`);
229 }
230 if (to && to[0] !== '/') {
231 handleConfigError(`scripts[${scriptId}]: "--to ${to}" must be a URL path, and start with a "/"`);
232 }
233 let dirDisk = cmdArr[0];
234 const dirUrl = to || `/${cmdArr[0]}`;
235 // mount:web_modules is a special case script where the fromDisk
236 // arg is hard-coded to match the internal dependency directory.
237 if (scriptId === 'mount:web_modules') {
238 dirDisk = DEV_DEPENDENCIES_DIR;
239 }
240 newScriptConfig.args = {
241 fromDisk: path.posix.normalize(dirDisk + '/'),
242 toUrl: path.posix.normalize(dirUrl + '/'),
243 };
244 }
245 processedScripts.push(newScriptConfig);
246 }
247 const allBuildMatch = new Set();
248 for (const { type, match } of processedScripts) {
249 if (type !== 'build') {
250 continue;
251 }
252 for (const ext of match) {
253 if (allBuildMatch.has(ext)) {
254 handleConfigError(`Multiple "scripts" match the "${ext}" file extension.\nCurrently, only one script per file type is supported.`);
255 }
256 allBuildMatch.add(prefixDot(ext));
257 }
258 }
259 if (!scripts['mount:web_modules']) {
260 processedScripts.push({
261 id: 'mount:web_modules',
262 type: 'mount',
263 match: ['web_modules'],
264 cmd: `mount $WEB_MODULES --to /web_modules`,
265 args: {
266 fromDisk: DEV_DEPENDENCIES_DIR,
267 toUrl: '/web_modules',
268 },
269 });
270 }
271 const defaultBuildMatch = ['.js', '.jsx', '.ts', '.tsx'].filter((ext) => !allBuildMatch.has(ext));
272 if (defaultBuildMatch.length > 0) {
273 const defaultBuildWorkerConfig = {
274 id: `build:${defaultBuildMatch.join(',')}`,
275 type: 'build',
276 match: defaultBuildMatch,
277 cmd: '(default) esbuild',
278 plugin: esbuildPlugin(),
279 };
280 processedScripts.push(defaultBuildWorkerConfig);
281 }
282 processedScripts.sort((a, b) => {
283 if (a.type === b.type) {
284 if (a.id === 'mount:web_modules') {
285 return -1;
286 }
287 if (b.id === 'mount:web_modules') {
288 return 1;
289 }
290 return a.id.localeCompare(b.id);
291 }
292 return SCRIPT_TYPES_WEIGHTED[a.type] - SCRIPT_TYPES_WEIGHTED[b.type];
293 });
294 return processedScripts;
295}
296function normalizeProxies(proxies) {
297 return Object.entries(proxies).map(([pathPrefix, options]) => {
298 if (typeof options !== 'string') {
299 return [
300 pathPrefix,
301 {
302 //@ts-ignore - Seems to be a strange 3.9.x bug
303 on: {},
304 ...options,
305 },
306 ];
307 }
308 return [
309 pathPrefix,
310 {
311 on: {
312 proxyReq: (proxyReq, req) => {
313 const proxyPath = proxyReq.path.split(req.url)[0];
314 proxyReq.path = proxyPath + req.url.replace(pathPrefix, '');
315 },
316 },
317 target: options,
318 changeOrigin: true,
319 secure: false,
320 },
321 ];
322 });
323}
324/** resolve --dest relative to cwd, etc. */
325function normalizeConfig(config) {
326 const cwd = process.cwd();
327 config.knownEntrypoints = config.install || [];
328 config.installOptions.dest = path.resolve(cwd, config.installOptions.dest);
329 config.devOptions.out = path.resolve(cwd, config.devOptions.out);
330 config.exclude = Array.from(new Set([...ALWAYS_EXCLUDE, ...config.exclude]));
331 if (!config.scripts) {
332 config.exclude.push('**/.*');
333 config.scripts = {
334 'mount:*': 'mount . --to /',
335 };
336 }
337 if (!config.proxy) {
338 config.proxy = {};
339 }
340 const allPlugins = {};
341 // remove leading/trailing slashes
342 config.buildOptions.metaDir = config.buildOptions.metaDir
343 .replace(/^(\/|\\)/g, '') // replace leading slash
344 .replace(/(\/|\\)$/g, ''); // replace trailing slash
345 config.plugins = config.plugins.map((plugin) => {
346 const configPluginPath = Array.isArray(plugin) ? plugin[0] : plugin;
347 const configPluginOptions = (Array.isArray(plugin) && plugin[1]) || {};
348 const configPluginLoc = require.resolve(configPluginPath, { paths: [cwd] });
349 const configPlugin = require(configPluginLoc)(config, configPluginOptions);
350 if ((configPlugin.build ? 1 : 0) +
351 (configPlugin.transform ? 1 : 0) +
352 (configPlugin.bundle ? 1 : 0) >
353 1) {
354 handleConfigError(`plugin[${configPluginLoc}]: A valid plugin can only have one build(), transform(), or bundle() function.`);
355 }
356 allPlugins[configPluginPath] = configPlugin;
357 if (configPlugin.knownEntrypoints) {
358 config.knownEntrypoints.push(...configPlugin.knownEntrypoints);
359 }
360 if (configPlugin.defaultBuildScript &&
361 !config.scripts[configPlugin.defaultBuildScript] &&
362 !Object.values(config.scripts).includes(configPluginPath)) {
363 config.scripts[configPlugin.defaultBuildScript] = configPluginPath;
364 }
365 return configPlugin;
366 });
367 if (config.devOptions.bundle === true && !config.scripts['bundle:*']) {
368 handleConfigError(`--bundle set to true, but no "bundle:*" script/plugin was provided.`);
369 }
370 config = handleLegacyProxyScripts(config);
371 config.proxy = normalizeProxies(config.proxy);
372 config.scripts = normalizeScripts(config.scripts);
373 config.scripts.forEach((script) => {
374 if (script.plugin)
375 return;
376 // Ensure plugins are properly registered/configured
377 if (['build', 'bundle'].includes(script.type)) {
378 if (allPlugins[script.cmd]?.[script.type]) {
379 script.plugin = allPlugins[script.cmd];
380 }
381 else if (allPlugins[script.cmd] && !allPlugins[script.cmd][script.type]) {
382 handleConfigError(`scripts[${script.id}]: Plugin "${script.cmd}" has no ${script.type} script.`);
383 }
384 else if (script.cmd.startsWith('@') || script.cmd.startsWith('.')) {
385 handleConfigError(`scripts[${script.id}]: Register plugin "${script.cmd}" in your Snowpack "plugins" config.`);
386 }
387 }
388 });
389 return config;
390}
391function handleConfigError(msg) {
392 console.error(`[error]: ${msg}`);
393 process.exit(1);
394}
395function handleValidationErrors(filepath, errors) {
396 console.error(colors.red(`! ${filepath || 'Configuration error'}`));
397 console.error(errors.map((err) => ` - ${err.toString()}`).join('\n'));
398 console.error(` See https://www.snowpack.dev/#configuration for more info.`);
399 process.exit(1);
400}
401function handleDeprecatedConfigError(mainMsg, ...msgs) {
402 console.error(colors.red(mainMsg));
403 msgs.forEach(console.error);
404 console.error(`See https://www.snowpack.dev/#configuration for more info.`);
405 process.exit(1);
406}
407function validateConfigAgainstV1(rawConfig, cliFlags) {
408 // Moved!
409 if (rawConfig.dedupe || cliFlags.dedupe) {
410 handleDeprecatedConfigError('[Snowpack v1 -> v2] `dedupe` is now `installOptions.rollup.dedupe`.');
411 }
412 if (rawConfig.namedExports) {
413 handleDeprecatedConfigError('[Snowpack v1 -> v2] `rollup.namedExports` is no longer required. See also: installOptions.namedExports');
414 }
415 if (rawConfig.installOptions?.rollup?.namedExports) {
416 delete rawConfig.installOptions.rollup.namedExports;
417 console.error(colors.yellow('[Snowpack v2.3.0] `rollup.namedExports` is no longer required. See also: installOptions.namedExports'));
418 }
419 if (rawConfig.rollup) {
420 handleDeprecatedConfigError('[Snowpack v1 -> v2] top-level `rollup` config is now `installOptions.rollup`.');
421 }
422 if (rawConfig.installOptions?.include || cliFlags.include) {
423 handleDeprecatedConfigError('[Snowpack v1 -> v2] `installOptions.include` is now handled via "mount" build scripts!');
424 }
425 if (rawConfig.installOptions?.exclude) {
426 handleDeprecatedConfigError('[Snowpack v1 -> v2] `installOptions.exclude` is now `exclude`.');
427 }
428 if (Array.isArray(rawConfig.webDependencies)) {
429 handleDeprecatedConfigError('[Snowpack v1 -> v2] The `webDependencies` array is now `install`.');
430 }
431 if (rawConfig.knownEntrypoints) {
432 handleDeprecatedConfigError('[Snowpack v1 -> v2] `knownEntrypoints` is now `install`.');
433 }
434 if (rawConfig.entrypoints) {
435 handleDeprecatedConfigError('[Snowpack v1 -> v2] `entrypoints` is now `install`.');
436 }
437 if (rawConfig.include) {
438 handleDeprecatedConfigError('[Snowpack v1 -> v2] All files are now included by default. "include" config is safe to remove.', 'Whitelist & include specific folders via "mount" build scripts.');
439 }
440 // Replaced!
441 if (rawConfig.source || cliFlags.source) {
442 handleDeprecatedConfigError('[Snowpack v1 -> v2] `source` is now detected automatically, this config is safe to remove.');
443 }
444 if (rawConfig.stat || cliFlags.stat) {
445 handleDeprecatedConfigError('[Snowpack v1 -> v2] `stat` is now the default output, this config is safe to remove.');
446 }
447 if (rawConfig.scripts &&
448 Object.keys(rawConfig.scripts).filter((k) => k.startsWith('lintall')).length > 0) {
449 handleDeprecatedConfigError('[Snowpack v1 -> v2] `scripts["lintall:..."]` has been renamed to scripts["run:..."]');
450 }
451 if (rawConfig.scripts &&
452 Object.keys(rawConfig.scripts).filter((k) => k.startsWith('plugin:`')).length > 0) {
453 handleDeprecatedConfigError('[Snowpack v1 -> v2] `scripts["plugin:..."]` have been renamed to scripts["build:..."].');
454 }
455 // Removed!
456 if (rawConfig.devOptions?.dist) {
457 handleDeprecatedConfigError('[Snowpack v1 -> v2] `devOptions.dist` is no longer required. This config is safe to remove.', `If you'd still like to host your src/ directory at the "/_dist/*" URL, create a mount script:',
458 ' {"scripts": {"mount:src": "mount src --to /_dist_"}} `);
459 }
460 if (rawConfig.hash || cliFlags.hash) {
461 handleDeprecatedConfigError('[Snowpack v1 -> v2] `installOptions.hash` has been replaced by `snowpack build`.');
462 }
463 if (rawConfig.installOptions?.nomodule || cliFlags.nomodule) {
464 handleDeprecatedConfigError('[Snowpack v1 -> v2] `installOptions.nomodule` has been replaced by `snowpack build`.');
465 }
466 if (rawConfig.installOptions?.nomoduleOutput || cliFlags.nomoduleOutput) {
467 handleDeprecatedConfigError('[Snowpack v1 -> v2] `installOptions.nomoduleOutput` has been replaced by `snowpack build`.');
468 }
469 if (rawConfig.installOptions?.babel || cliFlags.babel) {
470 handleDeprecatedConfigError('[Snowpack v1 -> v2] `installOptions.babel` has been replaced by `snowpack build`.');
471 }
472 if (rawConfig.installOptions?.optimize || cliFlags.optimize) {
473 handleDeprecatedConfigError('[Snowpack v1 -> v2] `installOptions.optimize` has been replaced by `snowpack build`.');
474 }
475 if (rawConfig.installOptions?.strict || cliFlags.strict) {
476 handleDeprecatedConfigError('[Snowpack v1 -> v2] `installOptions.strict` is no longer supported.');
477 }
478}
479export function createConfiguration(config) {
480 const { errors: validationErrors } = validate(config, configSchema, {
481 propertyName: CONFIG_NAME,
482 allowUnknownAttributes: false,
483 });
484 if (validationErrors.length > 0) {
485 return [validationErrors, undefined];
486 }
487 const mergedConfig = merge([DEFAULT_CONFIG, config]);
488 return [null, normalizeConfig(mergedConfig)];
489}
490export function loadAndValidateConfig(flags, pkgManifest) {
491 const explorerSync = cosmiconfigSync(CONFIG_NAME, {
492 // only support these 3 types of config for now
493 searchPlaces: ['package.json', 'snowpack.config.js', 'snowpack.config.json'],
494 // don't support crawling up the folder tree:
495 stopDir: path.dirname(process.cwd()),
496 });
497 let result;
498 // if user specified --config path, load that
499 if (flags.config) {
500 result = explorerSync.load(path.resolve(process.cwd(), flags.config));
501 if (!result) {
502 handleConfigError(`Could not locate Snowpack config at ${flags.config}`);
503 }
504 }
505 // If no config was found above, search for one.
506 result = result || explorerSync.search();
507 // If still no config found, assume none exists and use the default config.
508 if (!result || !result.config || result.isEmpty) {
509 result = { config: { ...DEFAULT_CONFIG } };
510 }
511 // validate against schema; throw helpful user if invalid
512 const config = result.config;
513 validateConfigAgainstV1(config, flags);
514 const cliConfig = expandCliFlags(flags);
515 let extendConfig = {};
516 if (config.extends) {
517 const extendConfigLoc = config.extends.startsWith('.')
518 ? path.resolve(path.dirname(result.filepath), config.extends)
519 : require.resolve(config.extends, { paths: [process.cwd()] });
520 const extendResult = explorerSync.load(extendConfigLoc);
521 if (!extendResult) {
522 handleConfigError(`Could not locate Snowpack config at ${flags.config}`);
523 process.exit(1);
524 }
525 extendConfig = extendResult.config;
526 const extendValidation = validate(extendConfig, configSchema, {
527 allowUnknownAttributes: false,
528 propertyName: CONFIG_NAME,
529 });
530 if (extendValidation.errors && extendValidation.errors.length > 0) {
531 handleValidationErrors(result.filepath, extendValidation.errors);
532 process.exit(1);
533 }
534 }
535 // if valid, apply config over defaults
536 const mergedConfig = merge([
537 pkgManifest.homepage ? { buildOptions: { baseUrl: pkgManifest.homepage } } : {},
538 extendConfig,
539 { webDependencies: pkgManifest.webDependencies },
540 config,
541 cliConfig,
542 ]);
543 for (const webDependencyName of Object.keys(mergedConfig.webDependencies || {})) {
544 if (pkgManifest.dependencies && pkgManifest.dependencies[webDependencyName]) {
545 handleConfigError(`"${webDependencyName}" is included in "webDependencies". Please remove it from your package.json "dependencies" config.`);
546 }
547 if (pkgManifest.devDependencies && pkgManifest.devDependencies[webDependencyName]) {
548 handleConfigError(`"${webDependencyName}" is included in "webDependencies". Please remove it from your package.json "devDependencies" config.`);
549 }
550 }
551 const [validationErrors, configResult] = createConfiguration(mergedConfig);
552 if (validationErrors) {
553 handleValidationErrors(result.filepath, validationErrors);
554 process.exit(1);
555 }
556 return configResult;
557}