Skip to content

Commit 9d5c0fb

Browse files
committed
feat(pluginutils): emit ES2015-safe named exports for reserved-word keys in dataToEsm
1 parent 5f14319 commit 9d5c0fb

2 files changed

Lines changed: 58 additions & 13 deletions

File tree

packages/pluginutils/src/dataToEsm.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ function isWellFormedString(input: string): boolean {
7070
return !/\p{Surrogate}/u.test(input);
7171
}
7272

73+
// Matches the ECMAScript `IdentifierName` grammar, which (unlike a binding
74+
// identifier) also accepts reserved words such as `switch`/`await`. Such names
75+
// can be re-exported with the `export { _x as switch }` form, valid since ES2015.
76+
const identifierNameRE = /^[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*$/u;
77+
7378
const dataToEsm: DataToEsm = function dataToEsm(data, options = {}) {
7479
const t = options.compact ? '' : 'indent' in options ? options.indent : '\t';
7580
const _ = options.compact ? '' : ' ';
@@ -115,17 +120,27 @@ const dataToEsm: DataToEsm = function dataToEsm(data, options = {}) {
115120
defaultExportRows.push(
116121
`${stringify(key)}:${_}${serialize(value, options.compact ? null : t, '')}`
117122
);
118-
// A `default` key is skipped here and exposed only through the default
119-
// export object: a `... as default` re-export would clash with the trailing
120-
// `export default` and produce a duplicate default export (a SyntaxError).
121-
if (key !== 'default' && options.includeArbitraryNames && isWellFormedString(key)) {
122-
const variableName = `${arbitraryNamePrefix}${arbitraryNameExportRows.length}`;
123-
namedExportCode += `${declarationType} ${variableName}${_}=${_}${serialize(
124-
value,
125-
options.compact ? null : t,
126-
''
127-
)};${n}`;
128-
arbitraryNameExportRows.push(`${variableName} as ${JSON.stringify(key)}`);
123+
// A `default` key is exposed only through the default export object: a
124+
// `... as default` re-export would clash with the trailing `export default`
125+
// and produce a duplicate default export (a SyntaxError).
126+
if (key !== 'default') {
127+
// A valid `IdentifierName` that is not a legal binding identifier (a
128+
// reserved word or global, e.g. `switch`, `await`) is re-exported with the
129+
// unquoted `export { _x as switch }` form, valid since ES2015. Any other
130+
// key needs the quoted arbitrary-namespace form (ES2022+), which remains
131+
// opt-in via `includeArbitraryNames`.
132+
const isIdentifierName = identifierNameRE.test(key);
133+
if (isIdentifierName || (options.includeArbitraryNames && isWellFormedString(key))) {
134+
const variableName = `${arbitraryNamePrefix}${arbitraryNameExportRows.length}`;
135+
namedExportCode += `${declarationType} ${variableName}${_}=${_}${serialize(
136+
value,
137+
options.compact ? null : t,
138+
''
139+
)};${n}`;
140+
arbitraryNameExportRows.push(
141+
`${variableName} as ${isIdentifierName ? key : JSON.stringify(key)}`
142+
);
143+
}
129144
}
130145
}
131146
}

packages/pluginutils/test/dataToEsm.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ test('supports a compact argument', () => {
4444
{ compact: true, objectShorthand: false }
4545
)
4646
).toBe(
47-
'export var some={deep:{object:"definition",here:"here"}};export default{some:some,"else":{deep:{object:"definition",here:"here"}}};'
47+
'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"}}};'
4848
);
4949
});
5050

@@ -63,7 +63,10 @@ test('supports nested arrays', () => {
6363
});
6464

6565
test('serializes null', () => {
66-
expect(dataToEsm({ null: null })).toBe('export default {\n\t"null": null\n};\n');
66+
// `null` is a valid IdentifierName, so it is re-exported with the unquoted form.
67+
expect(dataToEsm({ null: null })).toBe(
68+
'var _arbitrary0 = null;\nexport {\n\t_arbitrary0 as null\n};\nexport default {\n\t"null": null\n};\n'
69+
);
6770
});
6871

6972
test('supports default only', () => {
@@ -125,3 +128,30 @@ test('does not emit a named export for a `default` key with includeArbitraryName
125128
dataToEsm({ default: 'a', normal: 'b' }, { namedExports: true, includeArbitraryNames: true })
126129
).toBe('export var normal = "b";\nexport default {\n\t"default": "a",\n\tnormal: normal\n};\n');
127130
});
131+
132+
test('exports reserved-word / global keys as ES2015-safe named exports', () => {
133+
// `switch` is a reserved word, `Promise` is a global — both are valid
134+
// IdentifierNames, so they are re-exported with the unquoted form without
135+
// `includeArbitraryNames`. `default` stays object-only.
136+
expect(
137+
dataToEsm(
138+
{ switch: 'a', Promise: 'b', default: 'c', normal: 'd' },
139+
{ namedExports: true, preferConst: true }
140+
)
141+
).toBe(
142+
'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'
143+
);
144+
});
145+
146+
test('keeps reserved-word exports unquoted even with includeArbitraryNames', () => {
147+
// Reserved words use the unquoted ES2015 form; only non-identifier-name keys
148+
// (e.g. `foo-bar`) use the quoted ES2022 arbitrary-namespace form.
149+
expect(
150+
dataToEsm(
151+
{ switch: 'a', 'foo-bar': 'b' },
152+
{ namedExports: true, preferConst: true, includeArbitraryNames: true }
153+
)
154+
).toBe(
155+
'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'
156+
);
157+
});

0 commit comments

Comments
 (0)