diff --git a/packages/pluginutils/README.md b/packages/pluginutils/README.md index e705cb912..8f8219802 100755 --- a/packages/pluginutils/README.md +++ b/packages/pluginutils/README.md @@ -143,7 +143,7 @@ export default function myPlugin(options = {}) { Transforms objects into tree-shakable ES Module imports. -Parameters: `(data: Object)`
+Parameters: `(data: Object, options: DataToEsmOptions)`
Returns: `String` #### `data` @@ -152,6 +152,12 @@ Type: `Object` An object to transform into an ES module. +#### `options` + +Type: `DataToEsmOptions` + +_Note: Please see the TypeScript definition for complete documentation of these options_ + #### Usage ```js @@ -167,7 +173,8 @@ const esModuleSource = dataToEsm( indent: '\t', preferConst: true, objectShorthand: true, - namedExports: true + namedExports: true, + includeArbitraryNames: false } ); /* diff --git a/packages/pluginutils/src/dataToEsm.ts b/packages/pluginutils/src/dataToEsm.ts index d51d59e1a..312017220 100755 --- a/packages/pluginutils/src/dataToEsm.ts +++ b/packages/pluginutils/src/dataToEsm.ts @@ -59,6 +59,17 @@ function serialize(obj: unknown, indent: Indent, baseIndent: string): string { return stringify(obj); } +// isWellFormed exists from Node.js 20 +const hasStringIsWellFormed = 'isWellFormed' in String.prototype; + +function isWellFormedString(input: string): boolean { + // @ts-expect-error String::isWellFormed exists from ES2024. tsconfig lib is set to ES6 + if (hasStringIsWellFormed) return input.isWellFormed(); + + // https://github.com/tc39/proposal-is-usv-string/blob/main/README.md#algorithm + return !/\p{Surrogate}/u.test(input); +} + const dataToEsm: DataToEsm = function dataToEsm(data, options = {}) { const t = options.compact ? '' : 'indent' in options ? options.indent : '\t'; const _ = options.compact ? '' : ' '; @@ -78,8 +89,19 @@ const dataToEsm: DataToEsm = function dataToEsm(data, options = {}) { return `export default${magic}${code};`; } + let maxUnderbarPrefixLength = 0; + for (const key of Object.keys(data)) { + const underbarPrefixLength = key.match(/^(_+)/)?.[0].length ?? 0; + if (underbarPrefixLength > maxUnderbarPrefixLength) { + maxUnderbarPrefixLength = underbarPrefixLength; + } + } + + const arbitraryNamePrefix = `${'_'.repeat(maxUnderbarPrefixLength + 1)}arbitrary`; + let namedExportCode = ''; const defaultExportRows = []; + const arbitraryNameExportRows: string[] = []; for (const [key, value] of Object.entries(data)) { if (key === makeLegalIdentifier(key)) { if (options.objectShorthand) defaultExportRows.push(key); @@ -93,11 +115,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)}`); + } } } - return `${namedExportCode}export default${_}{${n}${t}${defaultExportRows.join( + + const arbitraryExportCode = + arbitraryNameExportRows.length > 0 + ? `export${_}{${n}${t}${arbitraryNameExportRows.join(`,${n}${t}`)}${n}};${n}` + : ''; + const defaultExportCode = `export default${_}{${n}${t}${defaultExportRows.join( `,${n}${t}` )}${n}};${n}`; + + return `${namedExportCode}${arbitraryExportCode}${defaultExportCode}`; }; export { dataToEsm as default }; diff --git a/packages/pluginutils/test/dataToEsm.ts b/packages/pluginutils/test/dataToEsm.ts index d1d5d9ac5..d32fe3e24 100755 --- a/packages/pluginutils/test/dataToEsm.ts +++ b/packages/pluginutils/test/dataToEsm.ts @@ -110,3 +110,20 @@ test('avoid U+2029 U+2029 -0 be ignored by JSON.stringify, and avoid it return n 'export default[-0,"\\u2028\\u2029",undefined,undefined];' ); }); + +test('support arbitrary module namespace identifier names', (t) => { + t.is( + dataToEsm( + { foo: 'foo', 'foo.bar': 'foo.bar', '\udfff': 'non wellformed' }, + { namedExports: true, includeArbitraryNames: true } + ), + 'export var foo = "foo";\nvar _arbitrary0 = "foo.bar";\nexport {\n\t_arbitrary0 as "foo.bar"\n};\nexport default {\n\tfoo: foo,\n\t"foo.bar": "foo.bar",\n\t"\\udfff": "non wellformed"\n};\n' + ); + t.is( + dataToEsm( + { foo: 'foo', 'foo.bar': 'foo.bar', '\udfff': 'non wellformed' }, + { namedExports: true, includeArbitraryNames: true, compact: true } + ), + 'export var foo="foo";var _arbitrary0="foo.bar";export{_arbitrary0 as "foo.bar"};export default{foo:foo,"foo.bar":"foo.bar","\\udfff":"non wellformed"};' + ); +}); diff --git a/packages/pluginutils/types/index.d.ts b/packages/pluginutils/types/index.d.ts index fb96d6346..4bdf3a2a6 100755 --- a/packages/pluginutils/types/index.d.ts +++ b/packages/pluginutils/types/index.d.ts @@ -10,6 +10,12 @@ export interface AttachedScope { export interface DataToEsmOptions { compact?: boolean; + /** + * @desc When this option is set, dataToEsm will generate a named export for keys that + * are not a valid identifier, by leveraging the "Arbitrary Module Namespace Identifier + * Names" feature. See: https://github.com/tc39/ecma262/pull/2154 + */ + includeArbitraryNames?: boolean; indent?: string; namedExports?: boolean; objectShorthand?: boolean;