From 5f143195eca76541e5783736e9cc44f4ee074d96 Mon Sep 17 00:00:00 2001 From: semimikoh Date: Fri, 29 May 2026 13:27:19 +0900 Subject: [PATCH 1/2] fix(pluginutils): avoid duplicate default export for a 'default' key in dataToEsm --- packages/pluginutils/src/dataToEsm.ts | 5 ++++- packages/pluginutils/test/dataToEsm.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/pluginutils/src/dataToEsm.ts b/packages/pluginutils/src/dataToEsm.ts index 4be5d7ae4..679202c4b 100755 --- a/packages/pluginutils/src/dataToEsm.ts +++ b/packages/pluginutils/src/dataToEsm.ts @@ -115,7 +115,10 @@ const dataToEsm: DataToEsm = function dataToEsm(data, options = {}) { defaultExportRows.push( `${stringify(key)}:${_}${serialize(value, options.compact ? null : t, '')}` ); - if (options.includeArbitraryNames && isWellFormedString(key)) { + // A `default` key is skipped here and exposed only through the default + // export object: a `... as default` re-export would clash with the trailing + // `export default` and produce a duplicate default export (a SyntaxError). + if (key !== 'default' && options.includeArbitraryNames && isWellFormedString(key)) { const variableName = `${arbitraryNamePrefix}${arbitraryNameExportRows.length}`; namedExportCode += `${declarationType} ${variableName}${_}=${_}${serialize( value, diff --git a/packages/pluginutils/test/dataToEsm.ts b/packages/pluginutils/test/dataToEsm.ts index c43504a2e..23485e9cb 100755 --- a/packages/pluginutils/test/dataToEsm.ts +++ b/packages/pluginutils/test/dataToEsm.ts @@ -116,3 +116,12 @@ test('support arbitrary module namespace identifier names', () => { 'export var foo="foo";var _arbitrary0="foo.bar";export{_arbitrary0 as "foo.bar"};export default{foo:foo,"foo.bar":"foo.bar","\\udfff":"non wellformed"};' ); }); + +test('does not emit a named export for a `default` key with includeArbitraryNames', () => { + // `export { _x as "default" }` alongside the trailing `export default {...}` + // would be a duplicate default export (a SyntaxError), so a `default` key is + // only reachable through the default export object. + expect( + dataToEsm({ default: 'a', normal: 'b' }, { namedExports: true, includeArbitraryNames: true }) + ).toBe('export var normal = "b";\nexport default {\n\t"default": "a",\n\tnormal: normal\n};\n'); +}); From 9d5c0fb97726ec3c2ad7b26d458e977d10cbc08d Mon Sep 17 00:00:00 2001 From: semimikoh Date: Fri, 29 May 2026 14:13:14 +0900 Subject: [PATCH 2/2] feat(pluginutils): emit ES2015-safe named exports for reserved-word keys in dataToEsm --- packages/pluginutils/src/dataToEsm.ts | 37 ++++++++++++++++++-------- packages/pluginutils/test/dataToEsm.ts | 34 +++++++++++++++++++++-- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/pluginutils/src/dataToEsm.ts b/packages/pluginutils/src/dataToEsm.ts index 679202c4b..22fc15eb2 100755 --- a/packages/pluginutils/src/dataToEsm.ts +++ b/packages/pluginutils/src/dataToEsm.ts @@ -70,6 +70,11 @@ function isWellFormedString(input: string): boolean { return !/\p{Surrogate}/u.test(input); } +// Matches the ECMAScript `IdentifierName` grammar, which (unlike a binding +// identifier) also accepts reserved words such as `switch`/`await`. Such names +// can be re-exported with the `export { _x as switch }` form, valid since ES2015. +const identifierNameRE = /^[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*$/u; + const dataToEsm: DataToEsm = function dataToEsm(data, options = {}) { const t = options.compact ? '' : 'indent' in options ? options.indent : '\t'; const _ = options.compact ? '' : ' '; @@ -115,17 +120,27 @@ const dataToEsm: DataToEsm = function dataToEsm(data, options = {}) { defaultExportRows.push( `${stringify(key)}:${_}${serialize(value, options.compact ? null : t, '')}` ); - // A `default` key is skipped here and exposed only through the default - // export object: a `... as default` re-export would clash with the trailing - // `export default` and produce a duplicate default export (a SyntaxError). - if (key !== 'default' && options.includeArbitraryNames && isWellFormedString(key)) { - const variableName = `${arbitraryNamePrefix}${arbitraryNameExportRows.length}`; - namedExportCode += `${declarationType} ${variableName}${_}=${_}${serialize( - value, - options.compact ? null : t, - '' - )};${n}`; - arbitraryNameExportRows.push(`${variableName} as ${JSON.stringify(key)}`); + // A `default` key is exposed only through the default export object: a + // `... as default` re-export would clash with the trailing `export default` + // and produce a duplicate default export (a SyntaxError). + if (key !== 'default') { + // A valid `IdentifierName` that is not a legal binding identifier (a + // reserved word or global, e.g. `switch`, `await`) is re-exported with the + // unquoted `export { _x as switch }` form, valid since ES2015. Any other + // key needs the quoted arbitrary-namespace form (ES2022+), which remains + // opt-in via `includeArbitraryNames`. + const isIdentifierName = identifierNameRE.test(key); + if (isIdentifierName || (options.includeArbitraryNames && isWellFormedString(key))) { + const variableName = `${arbitraryNamePrefix}${arbitraryNameExportRows.length}`; + namedExportCode += `${declarationType} ${variableName}${_}=${_}${serialize( + value, + options.compact ? null : t, + '' + )};${n}`; + arbitraryNameExportRows.push( + `${variableName} as ${isIdentifierName ? key : JSON.stringify(key)}` + ); + } } } } diff --git a/packages/pluginutils/test/dataToEsm.ts b/packages/pluginutils/test/dataToEsm.ts index 23485e9cb..cafce5cad 100755 --- a/packages/pluginutils/test/dataToEsm.ts +++ b/packages/pluginutils/test/dataToEsm.ts @@ -44,7 +44,7 @@ test('supports a compact argument', () => { { compact: true, objectShorthand: false } ) ).toBe( - 'export var some={deep:{object:"definition",here:"here"}};export default{some:some,"else":{deep:{object:"definition",here:"here"}}};' + 'export var some={deep:{object:"definition",here:"here"}};var _arbitrary0={deep:{object:"definition",here:"here"}};export{_arbitrary0 as else};export default{some:some,"else":{deep:{object:"definition",here:"here"}}};' ); }); @@ -63,7 +63,10 @@ test('supports nested arrays', () => { }); test('serializes null', () => { - expect(dataToEsm({ null: null })).toBe('export default {\n\t"null": null\n};\n'); + // `null` is a valid IdentifierName, so it is re-exported with the unquoted form. + expect(dataToEsm({ null: null })).toBe( + 'var _arbitrary0 = null;\nexport {\n\t_arbitrary0 as null\n};\nexport default {\n\t"null": null\n};\n' + ); }); test('supports default only', () => { @@ -125,3 +128,30 @@ test('does not emit a named export for a `default` key with includeArbitraryName dataToEsm({ default: 'a', normal: 'b' }, { namedExports: true, includeArbitraryNames: true }) ).toBe('export var normal = "b";\nexport default {\n\t"default": "a",\n\tnormal: normal\n};\n'); }); + +test('exports reserved-word / global keys as ES2015-safe named exports', () => { + // `switch` is a reserved word, `Promise` is a global — both are valid + // IdentifierNames, so they are re-exported with the unquoted form without + // `includeArbitraryNames`. `default` stays object-only. + expect( + dataToEsm( + { switch: 'a', Promise: 'b', default: 'c', normal: 'd' }, + { namedExports: true, preferConst: true } + ) + ).toBe( + 'const _arbitrary0 = "a";\nconst _arbitrary1 = "b";\nexport const normal = "d";\nexport {\n\t_arbitrary0 as switch,\n\t_arbitrary1 as Promise\n};\nexport default {\n\t"switch": "a",\n\t"Promise": "b",\n\t"default": "c",\n\tnormal: normal\n};\n' + ); +}); + +test('keeps reserved-word exports unquoted even with includeArbitraryNames', () => { + // Reserved words use the unquoted ES2015 form; only non-identifier-name keys + // (e.g. `foo-bar`) use the quoted ES2022 arbitrary-namespace form. + expect( + dataToEsm( + { switch: 'a', 'foo-bar': 'b' }, + { namedExports: true, preferConst: true, includeArbitraryNames: true } + ) + ).toBe( + 'const _arbitrary0 = "a";\nconst _arbitrary1 = "b";\nexport {\n\t_arbitrary0 as switch,\n\t_arbitrary1 as "foo-bar"\n};\nexport default {\n\t"switch": "a",\n\t"foo-bar": "b"\n};\n' + ); +});