Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions packages/pluginutils/src/dataToEsm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? '' : ' ';
Expand Down Expand Up @@ -115,14 +120,27 @@ const dataToEsm: DataToEsm = function dataToEsm(data, options = {}) {
defaultExportRows.push(
`${stringify(key)}:${_}${serialize(value, options.compact ? null : t, '')}`
);
if (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)}`
);
}
}
}
}
Expand Down
43 changes: 41 additions & 2 deletions packages/pluginutils/test/dataToEsm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}};'
);
});

Expand All @@ -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', () => {
Expand Down Expand Up @@ -116,3 +119,39 @@ 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');
});

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'
);
});
Loading