From daf1c481cd51266e693de141d46a174904d8cb29 Mon Sep 17 00:00:00 2001 From: Morgan Ney Date: Sun, 28 Jan 2024 09:34:24 -0600 Subject: [PATCH] feat: support dynamic imports with comments. --- .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 1 + README.md | 13 +++ __tests__/__fixtures__/commented.js | 7 ++ __tests__/formatter.js | 6 +- __tests__/loader.spec.js | 120 ++++++++++++++++++++++++++++ __tests__/parser.js | 4 +- package-lock.json | 12 +-- package.json | 4 +- src/formatter.js | 64 ++++++++++----- src/loader.js | 9 ++- src/parser.js | 8 +- src/schema.js | 15 ++++ 13 files changed, 228 insertions(+), 37 deletions(-) create mode 100644 __tests__/__fixtures__/commented.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94b2913..ff983ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - name: Test run: npm test - name: Report Coverage - uses: codecov/codecov-action@v3.1.4 + uses: codecov/codecov-action@v3.1.5 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Lint diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ad78495..2181145 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,3 +33,4 @@ jobs: uses: JS-DevTools/npm-publish@v3.0.1 with: token: ${{ secrets.NPM_AUTH_TOKEN }} + tag: ${{ contains(github.ref, '-') && 'next' || 'latest' }} diff --git a/README.md b/README.md index 8722466..0e7e43e 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ The `webpackChunkName` comment is added by default when registering the loader. * [`verbose`](#verbose) * [`mode`](#mode) * [`match`](#match) +* [`comments`](#comments) * `[magicCommentName: string]: MagicCommentValue` see `magic-comments` [options](https://github.com/morganney/magic-comments#options) for details ### `verbose` @@ -75,6 +76,18 @@ Sets how the loader finds dynamic import expressions in your source code, either Sets how globs are matched, either the module file path, or the `import()` specifier. +### `comments` +**type** +```ts +'ignore' | 'prepend' | 'append' | 'replace' +| (cmts: Array<{ start: number; end: number; text: string }>, magicComment: string) => string +``` +**default** `'ignore'` + +_Note, this option only considers block comments that precede the dynamic imports specifier, and any comments coming after are ignored and left intact._ + +Sets how dynamic imports with block comments are handled. If `ignore` is used, then it will be skipped and no magic comments from your configuration will be applied. If `replace` is used, then all found comments will be replaced with the magic comments. `append` and `prepend` add the magic comments before, or after the found comments, respectively. If a function is used it will be passed the found comments, and the magic comment string that is to be applied. The return value has the same effect as `replace`. + ## Examples Below are examples for some of the supported magic comments. Consult the [loader specification](https://github.com/morganney/magic-comments-loader/blob/main/__tests__/loader.spec.js) for a comprehensive usage example. diff --git a/__tests__/__fixtures__/commented.js b/__tests__/__fixtures__/commented.js new file mode 100644 index 0000000..21df631 --- /dev/null +++ b/__tests__/__fixtures__/commented.js @@ -0,0 +1,7 @@ +import( + /* webpackChunkNames: "test-chunk" */ + /* something else */ + /* webpackFetchPriority: "high" */ + './folder/module.js' + /* after the specifier */ +) diff --git a/__tests__/formatter.js b/__tests__/formatter.js index 4cbf9fc..284408e 100644 --- a/__tests__/formatter.js +++ b/__tests__/formatter.js @@ -10,7 +10,8 @@ describe('format', () => { match: 'module', source: src, filepath: 'src/module.js', - comments: [{ start: openLen, end: openLen + commentLen, commentText: ' comment ' }], + comments: 'ignore', + astComments: [{ start: openLen, end: openLen + commentLen, text: ' comment ' }], magicCommentOptions: { webpackChunkName: true }, importExpressionNodes: [ { @@ -38,7 +39,8 @@ describe('format', () => { match: 'module', source: src, filepath: 'src/module.js', - comments: [], + comments: 'ignore', + astComments: [], magicCommentOptions: { webpackMode: () => 'invalid' }, importExpressionNodes: [ { diff --git a/__tests__/loader.spec.js b/__tests__/loader.spec.js index 9303ebd..63a88b6 100644 --- a/__tests__/loader.spec.js +++ b/__tests__/loader.spec.js @@ -1080,4 +1080,124 @@ describe('loader', () => { expect(output).toEqual(expect.stringContaining("import('./folder/module.js')")) }) + + it('updates imports with comments based on configuration', async () => { + const entry = '__fixtures__/commented.js' + let stats = await build(entry, { + use: { + loader: loaderPath, + options: { + comments: 'replace', + webpackChunkName: true + } + } + }) + let output = stats.toJson({ source: true }).modules[0].source + + expect(output).toMatch(/webpackChunkName: "folder-module"/) + expect(output).not.toMatch(/something else/) + + stats = await build(entry, { + use: { + loader: loaderPath, + options: { + comments: 'prepend', + webpackExports: () => ['a'] + } + } + }) + output = stats.toJson({ source: true }).modules[0].source + + expect(output).toMatch(/webpackExports: \["a"\]/) + expect(output).toMatch('webpackChunkNames: "test-chunk"') + expect(output.indexOf('webpackExports') < output.indexOf('webpackChunkName')).toBe( + true + ) + + stats = await build(entry, { + use: { + loader: loaderPath, + options: { + comments: 'append', + webpackExports: () => ['b'] + } + } + }) + output = stats.toJson({ source: true }).modules[0].source + + expect(output).toMatch(/webpackExports: \["b"\]/) + expect(output).toMatch('webpackFetchPriority: "high"') + expect( + output.indexOf('webpackExports') > output.indexOf('webpackFetchPriority') + ).toBe(true) + + let firstCmt = '' + let magicCmt = '' + + stats = await build(entry, { + use: { + loader: loaderPath, + options: { + comments: (cmts, magicComment) => { + firstCmt = cmts[0] + magicCmt = magicComment + return `${magicComment} /* ${cmts[0].text} */` + }, + webpackExports: () => ['c'] + } + } + }) + output = stats.toJson({ source: true }).modules[0].source + + expect(output).toMatch(/webpackExports: \["c"\]/) + expect(output).toMatch(firstCmt.text) + expect(output.indexOf(firstCmt.text) > output.indexOf(magicCmt)).toBe(true) + + stats = await build(entry, { + use: { + loader: loaderPath, + options: { + comments: () => { + return 123 + }, + webpackExports: () => ['c'] + } + } + }) + output = stats.toJson({ source: true }).modules[0].source + + // Return values other than strings result in no changes + expect(output).not.toMatch(/webpackExports: \["c"\]/) + expect(output).toMatch(firstCmt.text) + + stats = await build(entry, { + use: { + loader: loaderPath, + options: { + comments: 'prepend', + webpackExports: { + options: { + exports: () => ['c'] + }, + overrides: [ + { + files: '**/*.js', + options: { + exports: () => ['d'] + } + } + ] + } + } + } + }) + output = stats.toJson({ source: true }).modules[0].source + + // The `comments` option Should work with overrides too. + expect(output).toMatch(/webpackExports: \["d"\]/) + expect(output).toMatch('webpackChunkNames: "test-chunk"') + expect(output.indexOf('webpackExports') < output.indexOf('webpackChunkName')).toBe( + true + ) + }) }) diff --git a/__tests__/parser.js b/__tests__/parser.js index 11d77c7..21aca4a 100644 --- a/__tests__/parser.js +++ b/__tests__/parser.js @@ -23,8 +23,8 @@ describe('parse', () => { ) } ` - const { comments } = parse(src) + const { astComments } = parse(src) - expect(comments).toEqual([{ start: 175, end: 188, commentText: ' comment ' }]) + expect(astComments).toEqual([{ start: 175, end: 188, text: ' comment ' }]) }) }) diff --git a/package-lock.json b/package-lock.json index 8fb4ba2..f9daa70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "magic-comments-loader", - "version": "2.0.5", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "magic-comments-loader", - "version": "2.0.5", + "version": "2.1.0", "license": "MIT", "dependencies": { "acorn": "^8.9.0", @@ -16,7 +16,7 @@ "magic-comments": "^2.1.12", "magic-string": "^0.30.0", "micromatch": "^4.0.4", - "schema-utils": "^4.1.0" + "schema-utils": "^4.2.0" }, "devDependencies": { "@babel/cli": "^7.23.9", @@ -9340,9 +9340,9 @@ "dev": true }, "node_modules/schema-utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.1.0.tgz", - "integrity": "sha512-Jw+GZVbP5IggB2WAn6UHI02LBwGmsIeYN/lNbSMZyDziQ7jmtAUrqKqDja+W89YHVs+KL/3IkIMltAklqB1vAw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/package.json b/package.json index 7fe6ca1..e9e852d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "magic-comments-loader", - "version": "2.0.5", + "version": "2.1.0", "description": "Add webpack magic comments to your dynamic imports at build time.", "main": "dist", "type": "module", @@ -68,7 +68,7 @@ "magic-comments": "^2.1.12", "magic-string": "^0.30.0", "micromatch": "^4.0.4", - "schema-utils": "^4.1.0" + "schema-utils": "^4.2.0" }, "prettier": { "printWidth": 90, diff --git a/src/formatter.js b/src/formatter.js index cf4e68c..787df32 100644 --- a/src/formatter.js +++ b/src/formatter.js @@ -4,27 +4,25 @@ import MagicString from 'magic-string' const format = ({ match, source, - filepath, comments, + filepath, + astComments, magicCommentOptions, importExpressionNodes }) => { const magicImports = [] - const cmts = [...comments] const src = new MagicString(source) - const hasComment = node => { - const idx = cmts.findIndex(cmt => cmt.start > node.start && cmt.end < node.end) - const wasFound = idx > -1 - - if (wasFound) { - cmts.splice(idx, 1) - } - - return wasFound + const getComments = node => { + // This ignores comments that come after the imports specifier. + return astComments.filter( + cmt => cmt.start > node.start && cmt.end < node.end && cmt.start < node.source.end + ) } for (const node of importExpressionNodes) { - if (!hasComment(node)) { + const cmts = getComments(node) + + if (!cmts.length || comments !== 'ignore') { const specifier = source.substring(node.source.start, node.source.end) const magicComment = getMagicComment({ match, @@ -34,13 +32,41 @@ const format = ({ }) if (magicComment) { - magicImports.push( - src - .snip(node.start, node.end) - .toString() - .replace(specifier, `${magicComment} ${specifier}`) - ) - src.appendLeft(node.source.start, `${magicComment} `) + const clone = src.snip(node.start, node.end) + + if (!cmts.length) { + magicImports.push( + clone.toString().replace(specifier, `${magicComment} ${specifier}`) + ) + src.appendLeft(node.source.start, `${magicComment} `) + } else { + /** + * Get the minimum start and maximum end. + * Assumption is that comment nodes are sorted + * in ascending order of `node.start`. + */ + const minStart = cmts[0].start + const maxEnd = cmts[cmts.length - 1].end + + if (comments === 'replace') { + magicImports.push(clone.overwrite(minStart, maxEnd, magicComment).toString()) + src.overwrite(minStart, maxEnd, magicComment) + } else if (comments === 'append') { + magicImports.push(clone.appendRight(maxEnd, ` ${magicComment}`).toString()) + src.appendRight(maxEnd, ` ${magicComment}`) + } else if (comments === 'prepend') { + magicImports.push(clone.prependLeft(minStart, `${magicComment} `).toString()) + src.prependLeft(minStart, `${magicComment} `) + } else { + // Has to be a function or the schema validator is broken + const replacement = comments(cmts, magicComment) + + if (typeof replacement === 'string') { + magicImports.push(clone.overwrite(minStart, maxEnd, replacement).toString()) + src.overwrite(minStart, maxEnd, replacement) + } + } + } } } } diff --git a/src/loader.js b/src/loader.js index f6a7166..f180dd0 100644 --- a/src/loader.js +++ b/src/loader.js @@ -17,7 +17,13 @@ const loader = function (source) { name: 'magic-comments-loader' }) - const { mode = 'parser', match = 'module', verbose = false, ...rest } = options + const { + mode = 'parser', + match = 'module', + comments = 'ignore', + verbose = false, + ...rest + } = options const magicCommentOptions = Object.keys(rest).length ? rest : { webpackChunkName: true } const filepath = this.resourcePath @@ -26,6 +32,7 @@ const loader = function (source) { ...parse(source), match, filepath, + comments, magicCommentOptions }) diff --git a/src/parser.js b/src/parser.js index 6698b85..e4f68e1 100644 --- a/src/parser.js +++ b/src/parser.js @@ -16,7 +16,7 @@ extend(base) const jsxParser = Parser.extend(jsx()) const parse = source => { - const comments = [] + const astComments = [] const importExpressionNodes = [] const ast = jsxParser.parse(source, { locations: false, @@ -25,9 +25,9 @@ const parse = source => { allowAwaitOutsideFunction: true, allowReturnOutsideFunction: true, allowImportExportEverywhere: true, - onComment: (isBlock, commentText, start, end) => { + onComment: (isBlock, text, start, end) => { if (isBlock) { - comments.push({ start, end, commentText }) + astComments.push({ start, end, text }) } } }) @@ -38,7 +38,7 @@ const parse = source => { } }) - return { ast, comments, importExpressionNodes, source } + return { ast, astComments, importExpressionNodes, source } } export { parse } diff --git a/src/schema.js b/src/schema.js index 16ad49d..5d60d2f 100644 --- a/src/schema.js +++ b/src/schema.js @@ -6,6 +6,21 @@ const schema = { ...magicSchema.properties, mode: { enum: ['parser', 'regexp'] + }, + comments: { + /** + * How to apply magic comments when the dynamic import already includes a block-level comment. + * + * - ignore: Default. Skip adding magic comments. + * - replace: Replace the found comment with any applied magic commments. + * - append: Add any applied magic comments after the comment. + * - preprend: Add any applied magic comments before the comment. + * - Function: (cmts: {}[], magicComment: string) => string + */ + oneOf: [ + { enum: ['ignore', 'replace', 'prepend', 'append'] }, + { instanceof: 'Function' } + ] } }, additionalProperties: false