diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5fc9ec..6497489 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: test: name: Node.js v${{ matrix.nodejs }} (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 3 strategy: matrix: nodejs: [10, 12, 14] diff --git a/README.md b/README.md index 2411336..4367c85 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ export default { }, plugins: [ svelte({ - // By default, all .svelte and .html files are compiled + // By default, all ".svelte" files are compiled extensions: ['.my-custom-extension'], // You can restrict which files are compiled diff --git a/index.d.ts b/index.d.ts index 973a784..25f9e05 100644 --- a/index.d.ts +++ b/index.d.ts @@ -19,8 +19,8 @@ type CssEmitter = (css: CssWriter) => any; interface Options extends CompileOptions { /** - * By default, all .svelte and .html files are compiled - * @default ['.html', '.svelte'] + * By default, all ".svelte" files are compiled + * @default ['.svelte'] */ extensions?: string[]; diff --git a/index.js b/index.js index 32180f4..fad4c49 100644 --- a/index.js +++ b/index.js @@ -1,68 +1,27 @@ const path = require('path'); -const { existsSync } = require('fs'); const relative = require('require-relative'); -const { version } = require('svelte/package.json'); const { createFilter } = require('rollup-pluginutils'); +const { compile, preprocess } = require('svelte/compiler'); const { encode, decode } = require('sourcemap-codec'); -const major_version = +version[0]; const pkg_export_errors = new Set(); -const { compile, preprocess } = major_version >= 3 - ? require('svelte/compiler.js') - : require('svelte'); - -function sanitize(input) { - return path - .basename(input) - .replace(path.extname(input), '') - .replace(/[^a-zA-Z_$0-9]+/g, '_') - .replace(/^_/, '') - .replace(/_$/, '') - .replace(/^(\d)/, '_$1'); -} - -function capitalize(str) { - return str[0].toUpperCase() + str.slice(1); -} +const plugin_options = new Set([ + 'include', 'exclude', 'extensions', + 'emitCss', 'preprocess', 'onwarn', +]); -const pluginOptions = { - include: true, - exclude: true, - extensions: true, - emitCss: true, - preprocess: true, - - // legacy — we might want to remove/change these in a future version - onwarn: true, - shared: true -}; - -function tryRequire(id) { - try { - return require(id); - } catch (err) { - return null; - } -} - -function tryResolve(pkg, importer) { - try { - return relative.resolve(pkg, importer); - } catch (err) { - if (err.code === 'MODULE_NOT_FOUND') return null; - if (err.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') { - pkg_export_errors.add(pkg.replace(/\/package.json$/, '')); - return null; - } - throw err; +function to_entry_css(bundle) { + for (let file in bundle) { + let { name } = path.parse(file); + return name + '.css'; } } class CssWriter { - constructor(context, bundle, isDev, code, filename, map) { + constructor(context, bundle, isDev, code, map) { this.code = code; - this.filename = filename; + this.filename = to_entry_css(bundle); this.map = map && { version: 3, @@ -119,243 +78,174 @@ class CssWriter { } } -module.exports = function svelte(options = {}) { +/** @returns {import('rollup').Plugin} */ +module.exports = function (options = {}) { + const extensions = options.extensions || ['.svelte']; const filter = createFilter(options.include, options.exclude); - const extensions = options.extensions || ['.html', '.svelte']; - - const fixed_options = {}; - - Object.keys(options).forEach(key => { - // add all options except include, exclude, extensions, and shared - if (pluginOptions[key]) return; - fixed_options[key] = options[key]; - }); - - if (major_version >= 3) { - fixed_options.format = 'esm'; - fixed_options.sveltePath = options.sveltePath || 'svelte'; - } else { - fixed_options.format = 'es'; - fixed_options.shared = require.resolve(options.shared || 'svelte/shared.js'); - } + /** @type {import('svelte/types/compiler/interfaces').ModuleFormat} */ + const format = 'esm', config = { format }; - // handle CSS extraction - if ('css' in options) { - if (typeof options.css !== 'function' && typeof options.css !== 'boolean') { - throw new Error('options.css must be a boolean or a function'); - } + for (let key in options) { + // forward `svelte/compiler` options + if (plugin_options.has(key)) continue; + config[key] = config[key] || options[key]; } - let css = options.css && typeof options.css === 'function' - ? options.css - : null; + const css_cache = new Map(); // [filename]:[chunk] + const { css, emitCss, onwarn } = options; - // A map from css filename to css contents - // If css: true we output all contents - // If emitCss: true we virtually resolve these imports - const cssLookup = new Map(); - - if (css || options.emitCss) { - fixed_options.css = false; + const ctype = typeof css; + const toWrite = ctype === 'function' && css; + if (css != null && !toWrite && ctype !== 'boolean') { + throw new Error('options.css must be a boolean or a function'); } + // block svelte's inline CSS if writer + const external_css = !!(toWrite || emitCss); + if (external_css) config.css = false; + return { name: 'svelte', /** - * Returns CSS contents for an id - */ - load(id) { - if (!cssLookup.has(id)) return null; - return cssLookup.get(id); - }, - - /** - * Returns id for import + * Resolve an import's full filepath. */ resolveId(importee, importer) { - if (cssLookup.has(importee)) { return importee; } - if (!importer || importee[0] === '.' || importee[0] === '\0' || path.isAbsolute(importee)) - return null; + if (css_cache.has(importee)) return importee; + if (!importer || importee[0] === '.' || importee[0] === '\0' || path.isAbsolute(importee)) return null; // if this is a bare import, see if there's a valid pkg.svelte const parts = importee.split('/'); - let name = parts.shift(); - if (name[0] === '@') name += `/${parts.shift()}`; - - const resolved = tryResolve( - `${name}/package.json`, - path.dirname(importer) - ); - if (!resolved) return null; - const pkg = tryRequire(resolved); - if (!pkg) return null; - const dir = path.dirname(resolved); + let dir, pkg, name = parts.shift(); + if (name[0] === '@') { + name += `/${parts.shift()}`; + } - if (parts.length === 0) { - // use pkg.svelte - if (pkg.svelte) { - return path.resolve(dir, pkg.svelte); - } - } else { - if (pkg['svelte.root']) { - // TODO remove this. it's weird and unnecessary - const sub = path.resolve(dir, pkg['svelte.root'], parts.join('/')); - if (existsSync(sub)) return sub; + try { + const file = `${name}/package.json`; + const resolved = relative.resolve(file, path.dirname(importer)); + dir = path.dirname(resolved); + pkg = require(resolved); + } catch (err) { + if (err.code === 'MODULE_NOT_FOUND') return null; + if (err.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') { + pkg_export_errors.add(name); + return null; } + throw err; + } + + // use pkg.svelte + if (parts.length === 0 && pkg.svelte) { + return path.resolve(dir, pkg.svelte); } }, /** - * Transforms a .svelte file into a .js file - * Adds a static import for virtual css file when emitCss: true + * Returns CSS contents for a file, if ours + */ + load(id) { + return css_cache.get(id) || null; + }, + + /** + * Transforms a `.svelte` file into a `.js` file. + * NOTE: If `emitCss: true`, appends a static `import` for virtual CSS file. */ - transform(code, id) { + async transform(code, id) { if (!filter(id)) return null; const extension = path.extname(id); if (!~extensions.indexOf(extension)) return null; + const dependencies = []; const filename = path.relative(process.cwd(), id); - const dependencies = []; - let preprocessPromise; if (options.preprocess) { - if (major_version < 3) { - const preprocessOptions = {}; - for (const key in options.preprocess) { - preprocessOptions[key] = (...args) => { - return Promise.resolve(options.preprocess[key](...args)).then( - resp => { - if (resp && resp.dependencies) { - dependencies.push(...resp.dependencies); - } - return resp; - } - ); - }; - } - preprocessPromise = preprocess( - code, - Object.assign(preprocessOptions, { filename }) - ).then(code => code.toString()); - } else { - preprocessPromise = preprocess(code, options.preprocess, { filename }).then(processed => { - if (processed.dependencies) { - dependencies.push(...processed.dependencies); - } - return processed.toString(); - }); - } - } else { - preprocessPromise = Promise.resolve(code); + const processed = await preprocess(code, options.preprocess, { filename }); + if (processed.dependencies) dependencies.push(...processed.dependencies); + code = processed.code; } - return preprocessPromise.then(code => { - let warnings = []; - - const base_options = major_version < 3 - ? { - onwarn: warning => warnings.push(warning) - } - : {}; - - const compiled = compile( - code, - Object.assign(base_options, fixed_options, { filename }, major_version >= 3 ? null : { - name: capitalize(sanitize(id)) - }) - ); + const compiled = compile(code, { ...config, filename }); - if (major_version >= 3) warnings = compiled.warnings || compiled.stats.warnings; - - warnings.forEach(warning => { - if ((!options.css && !options.emitCss) && warning.code === 'css-unused-selector') return; - - if (options.onwarn) { - options.onwarn(warning, warning => this.warn(warning)); - } else { - this.warn(warning); - } - }); - - if ((css || options.emitCss) && compiled.css.code) { - let fname = id.replace(new RegExp(`\\${extension}$`), '.css'); - - if (options.emitCss) { - const source_map_comment = `/*# sourceMappingURL=${compiled.css.map.toUrl()} */`; - compiled.css.code += `\n${source_map_comment}`; + (compiled.warnings || []).forEach(warning => { + if (!css && !emitCss && warning.code === 'css-unused-selector') return; + if (onwarn) onwarn(warning, this.warn); + else this.warn(warning); + }); - compiled.js.code += `\nimport ${JSON.stringify(fname)};\n`; - } + if (external_css && compiled.css.code) { + const fname = id.replace(new RegExp(`\\${extension}$`), '.css'); - cssLookup.set(fname, compiled.css); + if (emitCss) { + compiled.js.code += `\nimport ${JSON.stringify(fname)};\n`; + compiled.css.code += `\n/*# sourceMappingURL=${compiled.css.map.toUrl()} */`; } - if (this.addWatchFile) { - dependencies.forEach(dependency => this.addWatchFile(dependency)); - } else { - compiled.js.dependencies = dependencies; - } + css_cache.set(fname, compiled.css); + } - return compiled.js; - }); + if (this.addWatchFile) { + dependencies.forEach(this.addWatchFile); + } else { + compiled.js.dependencies = dependencies; + } + + return compiled.js; }, /** - * If css: true then outputs a single file with all CSS bundled together + * Write to CSS file if given `options.css` function. + * TODO: is there a better way to concat/append into Rollup asset? */ generateBundle(config, bundle) { - if (css) { - // TODO would be nice if there was a more idiomatic way to do this in Rollup - let result = ''; + if (pkg_export_errors.size > 0) { + console.warn('\nrollup-plugin-svelte: The following packages did not export their `package.json` file so we could not check the `svelte` field. If you had difficulties importing svelte components from a package, then please contact the author and ask them to export the package.json file.\n'); + console.warn(Array.from(pkg_export_errors, s => `- ${s}`).join('\n') + '\n'); + } - const sources = []; - const mappings = []; - const sourcesContent = config.sourcemapExcludeSources ? null : []; + if (!toWrite) return; - const chunks = Array.from(cssLookup.keys()).sort().map(key => cssLookup.get(key)); + let result = ''; + const sources = []; + const sourcesContent = config.sourcemapExcludeSources ? null : []; + const mappings = []; - for (let chunk of chunks) { - if (!chunk.code) continue; - result += chunk.code + '\n'; + [...css_cache.keys()].sort().forEach(file => { + const chunk = css_cache.get(file); + if (!chunk.code) return; - if (config.sourcemap && chunk.map) { - const len = sources.length; - sources.push(chunk.map.sources[0]); - if (sourcesContent) sourcesContent.push(chunk.map.sourcesContent[0]); + result += chunk.code + '\n'; - const decoded = decode(chunk.map.mappings); + if (config.sourcemap && chunk.map) { + const len = sources.length; + sources.push(chunk.map.sources[0]); + if (sourcesContent) sourcesContent.push(chunk.map.sourcesContent[0]); - if (len > 0) { - decoded.forEach(line => { - line.forEach(segment => { - segment[1] = len; - }); - }); - } + const decoded = decode(chunk.map.mappings); - mappings.push(...decoded); + if (len > 0) { + decoded.forEach(line => { + line.forEach(segment => { + segment[1] = len; + }); + }); } - } - const filename = Object.keys(bundle)[0].split('.').shift() + '.css'; + mappings.push(...decoded); + } + }); - const writer = new CssWriter(this, bundle, !!options.dev, result, filename, config.sourcemap && { + toWrite( + new CssWriter(this, bundle, !!options.dev, result, config.sourcemap && { sources, sourcesContent, mappings: encode(mappings) - }); - - css(writer); - } - - if (pkg_export_errors.size < 1) return; - - console.warn('\nrollup-plugin-svelte: The following packages did not export their `package.json` file so we could not check the `svelte` field. If you had difficulties importing svelte components from a package, then please contact the author and ask them to export the package.json file.\n'); - console.warn(Array.from(pkg_export_errors).map(s => `- ${s}`).join('\n') + '\n'); + }) + ); } }; }; diff --git a/test/index.js b/test/index.js index 0ffd7d5..9448b39 100644 --- a/test/index.js +++ b/test/index.js @@ -16,15 +16,7 @@ test('resolves using pkg.svelte', () => { const { resolveId } = plugin(); assert.is( resolveId('widget', path.resolve('test/foo/main.js')), - path.resolve('test/node_modules/widget/src/Widget.html') - ); -}); - -test('resolves using pkg.svelte.root', () => { - const { resolveId } = plugin(); - assert.is( - resolveId('widgets/Foo.html', path.resolve('test/foo/main.js')), - path.resolve('test/node_modules/widgets/src/Foo.html') + path.resolve('test/node_modules/widget/src/Widget.svelte') ); }); @@ -70,7 +62,7 @@ test('supports component name assignment', async () => { test('creates a {code, map, dependencies} object, excluding the AST etc', async () => { const { transform } = plugin(); - const compiled = await transform('', 'test.html') + const compiled = await transform('', 'test.svelte'); assert.equal(Object.keys(compiled), ['code', 'map', 'dependencies']); }); @@ -149,7 +141,7 @@ test('can generate a CSS sourcemap – a la Rollup config', async () => { name: originalFooLoc.name }, { - source: 'Foo.html', + source: 'Foo.svelte', line: 5, column: 1, name: null @@ -170,7 +162,7 @@ test('can generate a CSS sourcemap – a la Rollup config', async () => { name: originalBarLoc.name }, { - source: 'Bar.html', + source: 'Bar.svelte', line: 4, column: 1, name: null @@ -209,7 +201,7 @@ test('respects `sourcemapExcludeSources` Rollup option', async () => { assert.ok(css.map); assert.is(css.map.sources.length, 2); assert.is(css.map.sourcesContent, null); - assert.equal(css.map.sources, ['Bar.html', 'Foo.html']); + assert.equal(css.map.sources, ['Bar.svelte', 'Foo.svelte']); }); test('produces readable sourcemap output when `dev` is truthy', async () => { @@ -260,7 +252,7 @@ test('squelches CSS warnings if css: false', () => { color: red; } - `, 'test.html'); + `, 'test.svelte'); }); test('preprocesses components', async () => { @@ -282,10 +274,10 @@ test('preprocesses components', async () => {

Hello __REPLACEME__!

file: __FILENAME__

- `, 'test.html'); + `, 'test.svelte'); assert.is(code.indexOf('__REPLACEME__'), -1, 'content not modified'); - assert.is.not(code.indexOf('file: test.html'), -1, 'filename not replaced'); + assert.is.not(code.indexOf('file: test.svelte'), -1, 'filename not replaced'); assert.equal(dependencies, ['foo']); }); @@ -300,7 +292,7 @@ test('emits a CSS file', async () => { h1 { color: red; } - `, `path/to/Input.html`); + `, `path/to/Input.svelte`); assert.ok(transformed.code.indexOf(`import "path/to/Input.css";`) !== -1); @@ -313,7 +305,7 @@ test('emits a CSS file', async () => { column: 0 }); - assert.is(loc.source, 'Input.html'); + assert.is(loc.source, 'Input.svelte'); assert.is(loc.line, 4); assert.is(loc.column, 2); }); @@ -329,7 +321,7 @@ test('properly escapes CSS paths', async () => { h1 { color: red; } - `, `path\\t'o\\Input.html`); + `, `path\\t'o\\Input.svelte`); assert.ok(transformed.code.indexOf(`import "path\\\\t'o\\\\Input.css";`) !== -1); @@ -342,7 +334,7 @@ test('properly escapes CSS paths', async () => { column: 0 }); - assert.is(loc.source, 'Input.html'); + assert.is(loc.source, 'Input.svelte'); assert.is(loc.line, 4); assert.is(loc.column, 2); }); @@ -368,7 +360,7 @@ test('intercepts warnings', async () => { }, `

Hello world!

wheee!!! - `, 'test.html'); + `, 'test.svelte'); assert.equal(warnings.map(w => w.code), ['a11y-hidden', 'a11y-distracting-elements']); assert.equal(handled.map(w => w.code), ['a11y-hidden']); @@ -543,4 +535,67 @@ test('handles filenames that happen to contain .svelte', async () => { ); }); +test('ignores ".html" extension by default', async () => { + sander.rimrafSync('test/node_modules/widget/dist'); + sander.mkdirSync('test/node_modules/widget/dist'); + + try { + const bundle = await rollup({ + input: 'test/node_modules/widget/index.js', + external: ['svelte/internal'], + plugins: [ + plugin({ + css: false + }) + ] + }); + + await bundle.write({ + format: 'iife', + file: 'test/node_modules/widget/dist/bundle.js', + globals: { 'svelte/internal': 'svelte' }, + assetFileNames: '[name].[ext]', + sourcemap: true, + }); + + assert.unreachable('should have thrown PARSE_ERROR'); + } catch (err) { + assert.is(err.code, 'PARSE_ERROR'); + assert.match(err.message, 'Note that you need plugins to import files that are not JavaScript'); + assert.match(err.loc.file, /widget[\\\/]+src[\\\/]+Widget.html$/); + } +}); + +test('allows ".html" extension if configured', async () => { + sander.rimrafSync('test/node_modules/widget/dist'); + sander.mkdirSync('test/node_modules/widget/dist'); + + try { + const bundle = await rollup({ + input: 'test/node_modules/widget/index.js', + external: ['svelte/internal'], + plugins: [ + plugin({ + extensions: ['.html'], + css: false + }) + ] + }); + + await bundle.write({ + format: 'iife', + file: 'test/node_modules/widget/dist/bundle.js', + globals: { 'svelte/internal': 'svelte' }, + assetFileNames: '[name].[ext]', + sourcemap: true, + }); + } catch (err) { + console.log(err); + throw err; + } + + assert.ok(fs.existsSync('test/node_modules/widget/dist/bundle.js')); + assert.ok(fs.existsSync('test/node_modules/widget/dist/bundle.js.map')); +}); + test.run(); diff --git a/test/node_modules/widget/index.js b/test/node_modules/widget/index.js index e69de29..015904d 100644 --- a/test/node_modules/widget/index.js +++ b/test/node_modules/widget/index.js @@ -0,0 +1,5 @@ +import Widget from './src/Widget.html'; + +new Widget({ + target: document.body +}); diff --git a/test/node_modules/widget/package.json b/test/node_modules/widget/package.json index 7b86f24..174d949 100644 --- a/test/node_modules/widget/package.json +++ b/test/node_modules/widget/package.json @@ -1,4 +1,4 @@ { "main": "./index.js", - "svelte": "src/Widget.html" -} \ No newline at end of file + "svelte": "src/Widget.svelte" +} diff --git a/test/sourcemap-test/src/Bar.html b/test/sourcemap-test/src/Bar.svelte similarity index 86% rename from test/sourcemap-test/src/Bar.html rename to test/sourcemap-test/src/Bar.svelte index 9fbfc53..015c490 100644 --- a/test/sourcemap-test/src/Bar.html +++ b/test/sourcemap-test/src/Bar.svelte @@ -4,4 +4,4 @@ .bar { color: blue; } - \ No newline at end of file + diff --git a/test/sourcemap-test/src/Foo.html b/test/sourcemap-test/src/Foo.svelte similarity index 65% rename from test/sourcemap-test/src/Foo.html rename to test/sourcemap-test/src/Foo.svelte index 4a2dbc8..7dee3bf 100644 --- a/test/sourcemap-test/src/Foo.html +++ b/test/sourcemap-test/src/Foo.svelte @@ -8,5 +8,5 @@ \ No newline at end of file + import Bar from './Bar.svelte'; + diff --git a/test/sourcemap-test/src/main.js b/test/sourcemap-test/src/main.js index 39610f9..7faa337 100644 --- a/test/sourcemap-test/src/main.js +++ b/test/sourcemap-test/src/main.js @@ -1,5 +1,5 @@ -import Foo from './Foo.html'; +import Foo from './Foo.svelte'; new Foo({ target: document.body -}); \ No newline at end of file +});