diff --git a/.changeset/nine-spoons-attack.md b/.changeset/nine-spoons-attack.md new file mode 100644 index 000000000000..07c4058c3aa2 --- /dev/null +++ b/.changeset/nine-spoons-attack.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/package': minor +--- + +feat: add `packageJson` setting to adjust final `package.json` diff --git a/documentation/docs/30-advanced/70-packaging.md b/documentation/docs/30-advanced/70-packaging.md index 38d17a9c32b0..02a00270acbe 100644 --- a/documentation/docs/30-advanced/70-packaging.md +++ b/documentation/docs/30-advanced/70-packaging.md @@ -2,8 +2,6 @@ title: Packaging --- -> `svelte-package` is currently experimental. Non-backward compatible changes may occur in any future release. - You can use SvelteKit to build apps as well as component libraries, using the `@sveltejs/package` package (`npm create svelte` has an option to set this up for you). When you're creating an app, the contents of `src/routes` is the public-facing stuff; [`src/lib`](modules#$lib) contains your app's internal library. @@ -12,11 +10,11 @@ A component library has the exact same structure as a SvelteKit app, except that Running the `svelte-package` command from `@sveltejs/package` will take the contents of `src/lib` and generate a `package` directory (which can be [configured](configuration)) containing the following: -- All the files in `src/lib`, unless you [configure](configuration) custom `include`/`exclude` options. Svelte components will be preprocessed, TypeScript files will be transpiled to JavaScript. -- Type definitions (`d.ts` files) which are generated for Svelte, JavaScript and TypeScript files. You need to install `typescript >= 4.0.0` for this. Type definitions are placed next to their implementation, hand-written `d.ts` files are copied over as is. You can [disable generation](configuration), but we strongly recommend against it — people using your library might use TypeScript, for which they require these type definition files. -- A `package.json` copied from the project root with all fields except `"scripts"`, `"publishConfig.directory"` and `"publishConfig.linkDirectory"`. The `"dependencies"` field is included, which means you should add packages that you only need for your documentation or demo site to `"devDependencies"`. A `"type": "module"` and an `"exports"` field will be added if it's not defined in the original file. +- All the files in `src/lib`, unless you [configure](configuration) the custom `files` option. Svelte components will be preprocessed, TypeScript files will be transpiled to JavaScript. +- Type definitions (`d.ts` files) which are generated for Svelte, JavaScript and TypeScript files. You need to install `typescript >= 4.0.0` for this. Type definitions are placed next to their implementation, hand-written `d.ts` files are copied over as is. You can [disable generation](configuration) by setting `emitTypes: false`, but we strongly recommend against it — people using your library might use TypeScript, for which they require these type definition files. +- A `package.json` copied from the project root with all fields except `"scripts"`, `"publishConfig.directory"` and `"publishConfig.linkDirectory"`. The `"dependencies"` field is included, which means you should add packages that you only need for your documentation or demo site to `"devDependencies"`. A `"type": "module"` and an `"exports"` field will be added if it's not defined in the original file. You can customize the final `package.json` contents through the `packageJson` option, which is passed the original and generated `package.json`. If you return `undefined`, the `package.json` will not be written to the output directory. -The `"exports"` field contains the package's entry points. By default, all files in `src/lib` will be treated as an entry point unless they start with (or live in a directory that starts with) an underscore, but you can [configure](configuration) this behaviour. If you have a `src/lib/index.js` or `src/lib/index.svelte` file, it will be treated as the package root. +The `"exports"` field contains the package's entry points. By default, all files in `src/lib` will be treated as an entry point unless they start with (or live in a directory that starts with) an underscore, but you can [configure](configuration) this behaviour through the `packageJson` option. If you have a `src/lib/index.js` or `src/lib/index.svelte` file, it will be treated as the package root. For example, if you had a `src/lib/Foo.svelte` component and a `src/lib/index.js` module that re-exported it, a consumer of your library could do either of the following: diff --git a/packages/package/src/config.js b/packages/package/src/config.js index b094dee11e06..ae1f3fb460c0 100644 --- a/packages/package/src/config.js +++ b/packages/package/src/config.js @@ -24,13 +24,26 @@ export async function load_config({ cwd = process.cwd() } = {}) { * @returns {import('./types').ValidatedConfig} */ function process_config(config, { cwd = process.cwd() } = {}) { + let warned = false; + return { extensions: config.extensions ?? ['.svelte'], kit: config.kit, package: { source: path.resolve(cwd, config.kit?.files?.lib ?? config.package?.source ?? 'src/lib'), dir: config.package?.dir ?? 'package', - exports: config.package?.exports ?? ((filepath) => !/^_|\/_|\.d\.ts$/.test(filepath)), + exports: config.package?.exports + ? (filepath) => { + if (!warned) { + console.warn( + 'The `package.exports` option is deprecated. Use `package.packageJson` instead.' + ); + warned = true; + } + return /** @type {any} */ (config.package).exports(filepath); + } + : (filepath) => !/^_|\/_|\.d\.ts$/.test(filepath), + packageJson: config.package?.packageJson ?? ((_, pkg) => pkg), files: config.package?.files ?? (() => true), emitTypes: config.package?.emitTypes ?? true }, diff --git a/packages/package/src/index.js b/packages/package/src/index.js index e7af57df8140..e4998360cea7 100644 --- a/packages/package/src/index.js +++ b/packages/package/src/index.js @@ -4,7 +4,15 @@ import colors from 'kleur'; import chokidar from 'chokidar'; import { preprocess } from 'svelte/compiler'; import { copy, mkdirp, rimraf } from './filesystem.js'; -import { analyze, generate_pkg, resolve_lib_alias, scan, strip_lang_tags, write } from './utils.js'; +import { + analyze, + generate_pkg, + resolve_lib_alias, + scan, + strip_lang_tags, + write, + write_if_changed +} from './utils.js'; import { emit_dts, transpile_ts } from './typescript.js'; const essential_files = ['README', 'LICENSE', 'CHANGELOG', '.gitignore', '.npmignore']; @@ -30,39 +38,12 @@ export async function build(config, cwd = process.cwd()) { await emit_dts(config, cwd, files); } - const pkg = generate_pkg(cwd, files); + const { pkg, pkg_name } = generate_pkg(cwd, config.package.packageJson, files); - if (!pkg.dependencies?.svelte && !pkg.peerDependencies?.svelte) { - console.warn( - 'Svelte libraries should include "svelte" in either "dependencies" or "peerDependencies".' - ); + if (pkg) { + write_if_changed(join(dir, 'package.json'), JSON.stringify(pkg, null, 2)); } - if (!pkg.svelte && files.some((file) => file.is_svelte)) { - // Several heuristics in Kit/vite-plugin-svelte to tell Vite to mark Svelte packages - // rely on the "svelte" property. Vite/Rollup/Webpack plugin can all deal with it. - // See https://github.com/sveltejs/kit/issues/1959 for more info and related threads. - if (pkg.exports['.']) { - const svelte_export = - typeof pkg.exports['.'] === 'string' - ? pkg.exports['.'] - : pkg.exports['.'].import || pkg.exports['.'].default; - if (svelte_export) { - pkg.svelte = svelte_export; - } else { - console.warn( - 'Cannot generate a "svelte" entry point because the "." entry in "exports" is not a string. If you set it by hand, please also set one of the options as a "svelte" entry point\n' - ); - } - } else { - console.warn( - 'Cannot generate a "svelte" entry point because the "." entry in "exports" is missing. Please specify one or set a "svelte" entry point yourself\n' - ); - } - } - - write(join(dir, 'package.json'), JSON.stringify(pkg, null, 2)); - for (const file of files) { await process_file(config, file); } @@ -83,8 +64,10 @@ export async function build(config, cwd = process.cwd()) { const from = relative(cwd, lib); const to = relative(cwd, dir); console.log(colors.bold().green(`${from} -> ${to}`)); - console.log(`Successfully built '${pkg.name}' package. To publish it to npm:`); - console.log(colors.bold().cyan(` cd ${to}`)); + console.log(`Successfully built '${pkg_name}' package. To publish it to npm:`); + if (pkg) { + console.log(colors.bold().cyan(` cd ${to}`)); + } console.log(colors.bold().cyan(' npm publish\n')); } @@ -98,8 +81,7 @@ export async function watch(config, cwd = process.cwd()) { console.log(message); - const { source: lib } = config.package; - const { dir } = config.package; + const { dir, source: lib } = config.package; /** @type {Array<{ file: import('./types').File, type: string }>} */ const pending = []; @@ -129,7 +111,7 @@ export async function watch(config, cwd = process.cwd()) { pending.length = 0; for (const { file, type } of events) { - if ((type === 'unlink' || type === 'add') && file.is_exported) { + if (type === 'unlink' || type === 'add') { should_update_pkg = true; } @@ -161,9 +143,11 @@ export async function watch(config, cwd = process.cwd()) { } if (should_update_pkg) { - const pkg = generate_pkg(cwd, files); - write(join(dir, 'package.json'), JSON.stringify(pkg, null, 2)); - console.log('Updated package.json'); + const pkg = generate_pkg(cwd, config.package.packageJson, files); + const changed = write_if_changed(join(dir, 'package.json'), JSON.stringify(pkg, null, 2)); + if (changed) { + console.log('Updated package.json'); + } } if (config.package.emitTypes) { diff --git a/packages/package/src/utils.js b/packages/package/src/utils.js index 0019706103a7..e3d01f9d6aeb 100644 --- a/packages/package/src/utils.js +++ b/packages/package/src/utils.js @@ -66,6 +66,21 @@ export function write(file, contents) { fs.writeFileSync(file, contents); } +/** @type {Map} */ +let current = new Map(); +/** + * @param {string} file + * @param {string} contents + */ +export function write_if_changed(file, contents) { + if (current.get(file) !== contents) { + write(file, contents); + current.set(file, contents); + return true; + } + return false; +} + /** * @param {import('./types').ValidatedConfig} config * @returns {import('./types').File[]} @@ -106,10 +121,12 @@ export function analyze(config, file) { /** * @param {string} cwd + * @param {NonNullable} packageJson * @param {import('./types').File[]} files */ -export function generate_pkg(cwd, files) { +export function generate_pkg(cwd, packageJson, files) { const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')); + const original = JSON.parse(JSON.stringify(pkg)); // Remove fields that are specific to the original package.json // See: https://pnpm.io/package_json#publishconfigdirectory @@ -147,5 +164,37 @@ export function generate_pkg(cwd, files) { } } - return pkg; + if (!pkg.dependencies?.svelte && !pkg.peerDependencies?.svelte) { + console.warn( + 'Svelte libraries should include "svelte" in either "dependencies" or "peerDependencies".' + ); + } + + if (!pkg.svelte && files.some((file) => file.is_svelte)) { + // Several heuristics in Kit/vite-plugin-svelte to tell Vite to mark Svelte packages + // rely on the "svelte" property. Vite/Rollup/Webpack plugin can all deal with it. + // See https://github.com/sveltejs/kit/issues/1959 for more info and related threads. + if (pkg.exports['.']) { + const svelte_export = + typeof pkg.exports['.'] === 'string' + ? pkg.exports['.'] + : pkg.exports['.'].svelte || pkg.exports['.'].import || pkg.exports['.'].default; + if (svelte_export) { + pkg.svelte = svelte_export; + } else { + console.warn( + 'Cannot generate a "svelte" entry point because the "." entry in "exports" is not a string. If you set it by hand, please also set one of the options as a "svelte" entry point in your package.json\n' + + 'Example: { ..., "svelte": "./index.svelte" } }\n' + ); + } + } else { + console.warn( + 'Cannot generate a "svelte" entry point because the "." entry in "exports" is missing. Please specify one or set a "svelte" entry point yourself in your package.json\n' + + 'Example: { ..., "svelte": "./index.svelte" } }\n' + ); + } + } + + const final = packageJson(original, pkg); + return { pkg: packageJson(original, pkg), pkg_name: final?.name ?? original.name }; } diff --git a/packages/package/test/fixtures/package-json-omit/expected/index.d.ts b/packages/package/test/fixtures/package-json-omit/expected/index.d.ts new file mode 100644 index 000000000000..e45a2d5db142 --- /dev/null +++ b/packages/package/test/fixtures/package-json-omit/expected/index.d.ts @@ -0,0 +1 @@ +export const foo: true; diff --git a/packages/package/test/fixtures/package-json-omit/expected/index.js b/packages/package/test/fixtures/package-json-omit/expected/index.js new file mode 100644 index 000000000000..62d968e8238e --- /dev/null +++ b/packages/package/test/fixtures/package-json-omit/expected/index.js @@ -0,0 +1 @@ +export const foo = true; diff --git a/packages/package/test/fixtures/package-json-omit/jsconfig.json b/packages/package/test/fixtures/package-json-omit/jsconfig.json new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/packages/package/test/fixtures/package-json-omit/jsconfig.json @@ -0,0 +1 @@ +{} diff --git a/packages/package/test/fixtures/package-json-omit/package.json b/packages/package/test/fixtures/package-json-omit/package.json new file mode 100644 index 000000000000..c7c2d28a4f6b --- /dev/null +++ b/packages/package/test/fixtures/package-json-omit/package.json @@ -0,0 +1,7 @@ +{ + "name": "package-json-omit", + "private": true, + "version": "1.0.0", + "description": "omits package json", + "type": "module" +} diff --git a/packages/package/test/fixtures/package-json-omit/src/lib/index.js b/packages/package/test/fixtures/package-json-omit/src/lib/index.js new file mode 100644 index 000000000000..62d968e8238e --- /dev/null +++ b/packages/package/test/fixtures/package-json-omit/src/lib/index.js @@ -0,0 +1 @@ +export const foo = true; diff --git a/packages/package/test/fixtures/package-json-omit/svelte.config.js b/packages/package/test/fixtures/package-json-omit/svelte.config.js new file mode 100644 index 000000000000..7c7e2d4fe291 --- /dev/null +++ b/packages/package/test/fixtures/package-json-omit/svelte.config.js @@ -0,0 +1,7 @@ +const config = { + package: { + packageJson: () => undefined + } +}; + +export default config; diff --git a/packages/package/test/fixtures/package-json/expected/Test.svelte b/packages/package/test/fixtures/package-json/expected/Test.svelte new file mode 100644 index 000000000000..e60789af6a74 --- /dev/null +++ b/packages/package/test/fixtures/package-json/expected/Test.svelte @@ -0,0 +1,12 @@ + + + diff --git a/packages/package/test/fixtures/package-json/expected/Test.svelte.d.ts b/packages/package/test/fixtures/package-json/expected/Test.svelte.d.ts new file mode 100644 index 000000000000..71bb3d88958e --- /dev/null +++ b/packages/package/test/fixtures/package-json/expected/Test.svelte.d.ts @@ -0,0 +1,40 @@ +/** @typedef {typeof __propDef.props} TestProps */ +/** @typedef {typeof __propDef.events} TestEvents */ +/** @typedef {typeof __propDef.slots} TestSlots */ +export default class Test extends SvelteComponentTyped< + { + astring?: string; + }, + { + event: CustomEvent; + } & { + [evt: string]: CustomEvent; + }, + { + default: { + astring: string; + }; + } +> { + get astring(): string; +} +export type TestProps = typeof __propDef.props; +export type TestEvents = typeof __propDef.events; +export type TestSlots = typeof __propDef.slots; +import { SvelteComponentTyped } from 'svelte'; +declare const __propDef: { + props: { + astring?: string; + }; + events: { + event: CustomEvent; + } & { + [evt: string]: CustomEvent; + }; + slots: { + default: { + astring: string; + }; + }; +}; +export {}; diff --git a/packages/package/test/fixtures/package-json/expected/index.d.ts b/packages/package/test/fixtures/package-json/expected/index.d.ts new file mode 100644 index 000000000000..4c44188c3648 --- /dev/null +++ b/packages/package/test/fixtures/package-json/expected/index.d.ts @@ -0,0 +1 @@ +export { default as Test } from './Test.svelte'; diff --git a/packages/package/test/fixtures/package-json/expected/index.js b/packages/package/test/fixtures/package-json/expected/index.js new file mode 100644 index 000000000000..4c44188c3648 --- /dev/null +++ b/packages/package/test/fixtures/package-json/expected/index.js @@ -0,0 +1 @@ +export { default as Test } from './Test.svelte'; diff --git a/packages/package/test/fixtures/package-json/expected/package.json b/packages/package/test/fixtures/package-json/expected/package.json new file mode 100644 index 000000000000..ee23404b56bd --- /dev/null +++ b/packages/package/test/fixtures/package-json/expected/package.json @@ -0,0 +1,13 @@ +{ + "name": "package-json", + "private": true, + "version": "1.0.0", + "description": "uses package.json as is", + "type": "module", + "exports": { + ".": { + "import": "./index.js" + } + }, + "svelte": "./Test.svelte" +} diff --git a/packages/package/test/fixtures/package-json/jsconfig.json b/packages/package/test/fixtures/package-json/jsconfig.json new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/packages/package/test/fixtures/package-json/jsconfig.json @@ -0,0 +1 @@ +{} diff --git a/packages/package/test/fixtures/package-json/package.json b/packages/package/test/fixtures/package-json/package.json new file mode 100644 index 000000000000..ee23404b56bd --- /dev/null +++ b/packages/package/test/fixtures/package-json/package.json @@ -0,0 +1,13 @@ +{ + "name": "package-json", + "private": true, + "version": "1.0.0", + "description": "uses package.json as is", + "type": "module", + "exports": { + ".": { + "import": "./index.js" + } + }, + "svelte": "./Test.svelte" +} diff --git a/packages/package/test/fixtures/package-json/src/lib/Test.svelte b/packages/package/test/fixtures/package-json/src/lib/Test.svelte new file mode 100644 index 000000000000..e60789af6a74 --- /dev/null +++ b/packages/package/test/fixtures/package-json/src/lib/Test.svelte @@ -0,0 +1,12 @@ + + + diff --git a/packages/package/test/fixtures/package-json/src/lib/index.js b/packages/package/test/fixtures/package-json/src/lib/index.js new file mode 100644 index 000000000000..4c44188c3648 --- /dev/null +++ b/packages/package/test/fixtures/package-json/src/lib/index.js @@ -0,0 +1 @@ +export { default as Test } from './Test.svelte'; diff --git a/packages/package/test/fixtures/package-json/svelte.config.js b/packages/package/test/fixtures/package-json/svelte.config.js new file mode 100644 index 000000000000..3f16ceecafcd --- /dev/null +++ b/packages/package/test/fixtures/package-json/svelte.config.js @@ -0,0 +1,7 @@ +const config = { + package: { + packageJson: (original) => original + } +}; + +export default config; diff --git a/packages/package/test/index.js b/packages/package/test/index.js index 6a06e24bb1f5..6a0017acdb91 100644 --- a/packages/package/test/index.js +++ b/packages/package/test/index.js @@ -150,6 +150,14 @@ test('SvelteKit interop', async () => { await test_make_package('svelte-kit'); }); +test('packageJson option (use original package.json)', async () => { + await test_make_package('package-json'); +}); + +test('packageJson option (omit package.json)', async () => { + await test_make_package('package-json-omit'); +}); + // chokidar doesn't fire events in github actions :shrug: if (!process.env.CI) { test('watches for changes', async () => { diff --git a/packages/package/types/index.d.ts b/packages/package/types/index.d.ts index 0e1ba39b6364..d80552f2376b 100644 --- a/packages/package/types/index.d.ts +++ b/packages/package/types/index.d.ts @@ -17,12 +17,21 @@ export interface PackageConfig { /** * Function that determines if the given filepath will be included in the `exports` field * of the `package.json`. + * @deprecated use `packageJson` instead */ exports?(filepath: string): boolean; /** * Function that determines if the given file is part of the output. */ files?(filepath: string): boolean; + /** + * Is passed the original `package.json` and the generated one, and should return the final `package.json`. + * If `undefined` is returned, no `package.json` will be written to the output directory. + */ + packageJson?( + original: Record, + generated: Record + ): Record | undefined; } export interface Config {